From 1966535e1b08ed4bece2694faa791ca6c6f9a4df Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 09:19:54 +0300 Subject: [PATCH 001/116] Serve the chartable gateway networks to the trade panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector dropdown is fed by `connected-exchanges`, which filters DEX out by design and has other callers with a CEX-only contract. Rather than widen it, add a sibling source of truth for the DEX half: `fetch_gateway_networks` asks Gateway for its networks and keeps only the ones present in `NETWORK_TO_GECKO`. That intersection is the point. `dex_candles.uses_gecko_candles` is an exact-match test on the same dict, so offering only its members means a network can never be selected and then produce an empty chart — `solana-devnet` and `base-sepolia` would otherwise fall through to the Hummingbot candle path and 502. Registered with `strict=True` so an unreachable gateway raises instead of being cached as "this server has no DEX"; the route turns that into `{"networks": []}` so the panel degrades to CEX-only while the next request still retries. FEAT-041 --- condor/fetchers/__init__.py | 2 + condor/fetchers/connectors.py | 42 ++++++ condor/server_data_service.py | 10 ++ condor/web/routes/market.py | 24 ++++ tests/test_fetcher_gateway_networks.py | 184 +++++++++++++++++++++++++ 5 files changed, 262 insertions(+) create mode 100644 tests/test_fetcher_gateway_networks.py diff --git a/condor/fetchers/__init__.py b/condor/fetchers/__init__.py index 36e1adcc..6241959d 100644 --- a/condor/fetchers/__init__.py +++ b/condor/fetchers/__init__.py @@ -41,6 +41,7 @@ from condor.fetchers.connectors import ( fetch_connectors, fetch_available_cex_connectors, + fetch_gateway_networks, is_cex_connector, ) from condor.fetchers.executors import ( @@ -74,6 +75,7 @@ "fetch_trading_rules", "fetch_connectors", "fetch_available_cex_connectors", + "fetch_gateway_networks", "is_cex_connector", "fetch_executors", "fetch_all_executors", diff --git a/condor/fetchers/connectors.py b/condor/fetchers/connectors.py index 6d4cb144..0f4a975b 100644 --- a/condor/fetchers/connectors.py +++ b/condor/fetchers/connectors.py @@ -23,6 +23,48 @@ async def fetch_connectors(client, **_kw) -> List[str]: return await client.connectors.list_connectors() +def _network_id(item) -> str: + """The network id of a ``list_networks`` entry, whatever shape it arrives in. + + Gateway has returned plain strings and ``{"network_id": ...}`` / ``{"id": ...}`` + dicts across versions; the Telegram swap flow normalizes the same three shapes + (``handlers/dex/swap.py``). + """ + if isinstance(item, dict): + return str(item.get("network_id") or item.get("id") or item) + return str(item) + + +async def fetch_gateway_networks(client, strict: bool = False, **_kw) -> List[str]: + """Gateway networks that Condor can chart (subset of ``NETWORK_TO_GECKO``). + + The intersection is the point: a network is only offered to the trade panel if + ``dex_candles.uses_gecko_candles`` will answer for it, so selecting one can + never produce an empty chart. Do not widen this to every gateway network. + + Args: + strict: Raise when the gateway request itself fails, instead of reporting + that no networks exist. Callers that cache the answer want the + distinction: an unreachable gateway is worth retrying, and must not be + cached as "this server has no DEX". + """ + # Lazy, like condor.dex_candles.uses_gecko_candles — condor.fetchers must not + # import handlers at module scope. + from handlers.dex.pool_data import NETWORK_TO_GECKO + + try: + response = await client.gateway.list_networks() + networks = (response or {}).get("networks") or [] + return sorted( + {n for n in (_network_id(i) for i in networks) if n in NETWORK_TO_GECKO} + ) + except Exception as e: + if strict: + raise + logger.error("Error fetching gateway networks: %s", e, exc_info=True) + return [] + + async def fetch_available_cex_connectors( client, account_name: str = "master_account", strict: bool = False, **_kw ) -> List[str]: diff --git a/condor/server_data_service.py b/condor/server_data_service.py index 1d3cea7c..2d747fa9 100644 --- a/condor/server_data_service.py +++ b/condor/server_data_service.py @@ -45,6 +45,7 @@ class ServerDataType(Enum): CANDLE_CONNECTORS = "candle_connectors" SERVER_STATUS = "server_status" ALL_CONNECTORS = "all_connectors" + GATEWAY_NETWORKS = "gateway_networks" TICKERS = "tickers" TICKER_POOL = "ticker_pool" @@ -83,6 +84,11 @@ class DataTypeDefaults: ServerDataType.ALL_CONNECTORS: DataTypeDefaults( interval=300, ttl=600, stale_threshold=30 ), + # Gateway networks change ~never (a chain is added to the gateway config by + # hand), so a read is served from cache for half an hour. + ServerDataType.GATEWAY_NETWORKS: DataTypeDefaults( + interval=300, ttl=1800, stale_threshold=300 + ), ServerDataType.TICKERS: DataTypeDefaults(interval=60, ttl=180, stale_threshold=30), # Whole-server ticker pool: one poll feeds every per-connector ticker view and # all currency conversion, so reads never hit the network. @@ -848,6 +854,7 @@ def register_default_fetches() -> None: fetch_connectors, fetch_current_price, fetch_executors, + fetch_gateway_networks, fetch_portfolio, fetch_positions, fetch_server_status, @@ -874,6 +881,9 @@ def register_default_fetches() -> None: ServerDataType.CONNECTORS, partial(fetch_available_cex_connectors, strict=True) ) sds.register_fetch(ServerDataType.ALL_CONNECTORS, fetch_connectors) + sds.register_fetch( + ServerDataType.GATEWAY_NETWORKS, partial(fetch_gateway_networks, strict=True) + ) sds.register_fetch(ServerDataType.BOTS_STATUS, fetch_bots_status) sds.register_fetch(ServerDataType.EXECUTORS, fetch_executors) sds.register_fetch(ServerDataType.BOT_RUNS, fetch_bot_runs) diff --git a/condor/web/routes/market.py b/condor/web/routes/market.py index 6c905862..428ec3ea 100644 --- a/condor/web/routes/market.py +++ b/condor/web/routes/market.py @@ -103,6 +103,30 @@ async def get_connected_exchanges(name: str, user: WebUser = Depends(get_current return result or [] +@router.get("/servers/{name}/market/gateway-networks") +async def get_gateway_networks(name: str, user: WebUser = Depends(get_current_user)): + """Gateway networks the trade panel can offer (chartable DEX venues). + + Answers ``{"networks": []}`` rather than a 502 when the gateway container is + down: the panel then simply shows no DEX. The fetcher runs ``strict=True``, so + the failure is never cached as an empty list and the next request retries. + """ + cm = get_config_manager() + if not cm.has_server_access(user.id, name): + raise HTTPException(status_code=403, detail="No access") + + from condor.server_data_service import ServerDataType, get_server_data_service + + try: + result = await get_server_data_service().get_or_fetch( + name, ServerDataType.GATEWAY_NETWORKS + ) + except Exception as e: + logger.warning("Gateway networks unavailable for %s: %s", name, e) + return {"networks": []} + return {"networks": result or []} + + @router.get("/servers/{name}/market/prices", response_model=MarketPriceResponse) async def get_price( name: str, diff --git a/tests/test_fetcher_gateway_networks.py b/tests/test_fetcher_gateway_networks.py new file mode 100644 index 00000000..a3dd7a6f --- /dev/null +++ b/tests/test_fetcher_gateway_networks.py @@ -0,0 +1,184 @@ +"""Tests for FEAT-041: ``fetch_gateway_networks`` normalizes and gates the list. + +The trade panel learns "this connector is a DEX" from this fetcher's output, so +two properties matter. First, gateway has returned ``list_networks`` entries as +plain strings and as ``network_id`` / ``id`` dicts across versions, and all three +have to reduce to the same id. Second, the result is intersected with +``NETWORK_TO_GECKO``: a network Condor cannot chart must never be offered, or the +user picks it and gets an empty chart (``dex_candles.uses_gecko_candles`` is an +exact-match membership test on that same dict). +""" + +import asyncio + +import pytest + +from condor.fetchers.connectors import fetch_gateway_networks + + +class FakeClient: + """Client whose ``gateway.list_networks`` replays one scripted response.""" + + def __init__(self, response=None, error=None): + self.calls = 0 + self.gateway = self._Gateway(self) + self._response = response + self._error = error + + class _Gateway: + def __init__(self, outer): + self._outer = outer + + async def list_networks(self): + self._outer.calls += 1 + if self._outer._error is not None: + raise self._outer._error + return self._outer._response + + +def _fetch(response, **kwargs): + return asyncio.run(fetch_gateway_networks(FakeClient(response), **kwargs)) + + +def test_accepts_plain_string_entries(): + result = _fetch({"networks": ["solana-mainnet-beta", "base-mainnet"]}) + assert result == ["base-mainnet", "solana-mainnet-beta"] + + +def test_accepts_network_id_dicts(): + result = _fetch({"networks": [{"network_id": "solana-mainnet-beta"}]}) + assert result == ["solana-mainnet-beta"] + + +def test_accepts_id_dicts(): + result = _fetch({"networks": [{"id": "ethereum-mainnet"}]}) + assert result == ["ethereum-mainnet"] + + +def test_mixed_shapes_normalize_to_the_same_ids(): + result = _fetch( + { + "networks": [ + "solana-mainnet-beta", + {"network_id": "base-mainnet"}, + {"id": "polygon-mainnet"}, + ] + } + ) + assert result == ["base-mainnet", "polygon-mainnet", "solana-mainnet-beta"] + + +def test_drops_networks_condor_cannot_chart(): + """A network absent from NETWORK_TO_GECKO would 502 on the candle path.""" + result = _fetch( + { + "networks": [ + "solana-mainnet-beta", + "solana-devnet", + "base-sepolia", + {"network_id": "not-a-chain"}, + ] + } + ) + assert result == ["solana-mainnet-beta"] + + +def test_deduplicates_and_sorts(): + result = _fetch( + {"networks": ["base-mainnet", {"network_id": "base-mainnet"}, "solana"]} + ) + assert result == ["base-mainnet", "solana"] + + +def test_tolerates_missing_and_empty_payloads(): + assert _fetch({}) == [] + assert _fetch({"networks": None}) == [] + assert _fetch(None) == [] + + +def test_strict_reraises_so_the_failure_is_not_cached(): + """A cached empty list would tell the panel "this server has no DEX".""" + client = FakeClient(error=RuntimeError("gateway down")) + with pytest.raises(RuntimeError): + asyncio.run(fetch_gateway_networks(client, strict=True)) + + +def test_non_strict_swallows_the_failure(): + client = FakeClient(error=RuntimeError("gateway down")) + assert asyncio.run(fetch_gateway_networks(client)) == [] + + +# ── The REST route ──────────────────────────────────────────────────────────── +# +# The panel merges this endpoint's answer into its connector dropdown, so a +# server whose gateway container is down must degrade to "no DEX offered" rather +# than break the whole selector with a 502. + + +class _FakeSds: + """Stands in for ServerDataService.get_or_fetch.""" + + def __init__(self, value=None, error=None): + self.value = value + self.error = error + self.calls = 0 + + async def get_or_fetch(self, name, data_type, **params): + self.calls += 1 + if self.error is not None: + raise self.error + return self.value + + +def _call_route(monkeypatch, sds): + from condor import server_data_service + from condor.web.models import WebUser + from condor.web.routes.market import get_gateway_networks + + monkeypatch.setattr( + server_data_service, "get_server_data_service", lambda: sds, raising=True + ) + + class _Cm: + def has_server_access(self, user_id, name): + return True + + monkeypatch.setattr( + "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True + ) + return asyncio.run(get_gateway_networks("srv", user=WebUser(id=1, role="user"))) + + +def test_route_returns_the_networks(monkeypatch): + sds = _FakeSds(value=["base-mainnet", "solana-mainnet-beta"]) + assert _call_route(monkeypatch, sds) == { + "networks": ["base-mainnet", "solana-mainnet-beta"] + } + + +def test_route_returns_empty_list_when_the_gateway_is_down(monkeypatch): + """Not a 500: the panel just shows no DEX.""" + sds = _FakeSds(error=RuntimeError("gateway down")) + assert _call_route(monkeypatch, sds) == {"networks": []} + + +def test_route_normalizes_a_none_answer(monkeypatch): + assert _call_route(monkeypatch, _FakeSds(value=None)) == {"networks": []} + + +def test_route_rejects_a_caller_without_access(monkeypatch): + from fastapi import HTTPException + + from condor.web.models import WebUser + from condor.web.routes.market import get_gateway_networks + + class _Cm: + def has_server_access(self, user_id, name): + return False + + monkeypatch.setattr( + "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True + ) + with pytest.raises(HTTPException) as e: + asyncio.run(get_gateway_networks("srv", user=WebUser(id=1, role="user"))) + assert e.value.status_code == 403 From 9dbc772e4cf78e858c3e0a1bb10638addd24d196 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 09:20:14 +0300 Subject: [PATCH 002/116] Make DEX connectors first-class in the trade panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gateway network can now be picked like `binance`: the chart draws from GeckoTerminal, and the panel collapses to what the venue actually supports. DEX-ness is learned as data. `connector-capabilities.ts` classifies by membership in the server's gateway-network list rather than by name, because the backend already carries four disagreeing connector predicates (`binance-smart-chain` is a gateway network that `is_cex_connector` calls a CEX) and a frontend prefix list would become the fifth and drift the same way. What stays a frontend literal is the map from kind to capabilities — which tabs and strategies to render is a UI decision, not a server fact. For a DEX the panel now offers Order only (`lp` is already declared, and lights up when FEAT-042 adds the tab), Market as the only execution strategy — there is no resting book to post to — and hides Depth and Markets. The endpoints with no DEX answer are switched off rather than left to 502: trading-rules, tickers, and `/market/prices`, whose job is taken over by the last candle close from the store TradeChart already streams. Pair entry becomes free text, since Gateway resolves whatever pair it is given and there is no list to browse; a base58 mint is a valid base, so normalization uppercases only the sides that are not addresses. Two corrections are held until both halves of the dropdown have loaded. Judging a persisted DEX network against the CEX list alone would bounce the selection back to `connectors[0]` on every reload and reset its tab. FEAT-041 --- .../components/executor/OrderConfigPanel.tsx | 30 ++- .../components/market/ExchangeSelector.tsx | 84 ++++++-- .../src/components/market/PairSelector.tsx | 199 +++++++++++++++++- .../src/components/market/PriceTicker.tsx | 10 +- frontend/src/components/market/useTickers.ts | 7 +- frontend/src/lib/api.ts | 6 + frontend/src/lib/connector-capabilities.ts | 74 +++++++ frontend/src/pages/CreateExecutor.tsx | 96 +++++++-- 8 files changed, 460 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/connector-capabilities.ts diff --git a/frontend/src/components/executor/OrderConfigPanel.tsx b/frontend/src/components/executor/OrderConfigPanel.tsx index 338140e5..ada09094 100644 --- a/frontend/src/components/executor/OrderConfigPanel.tsx +++ b/frontend/src/components/executor/OrderConfigPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useReducer } from "react"; +import { useEffect, useMemo, useReducer } from "react"; import { Sparkles } from "lucide-react"; import { @@ -179,12 +179,32 @@ interface Props { currentPrice: number | null; isSpot?: boolean; pair?: string; + /** + * Execution strategies this venue allows. A gateway swap has no resting order + * book, so a DEX passes `["MARKET"]`. Defaults to all of them. + */ + strategies?: string[]; } -export function OrderConfigPanel({ state, dispatch, validation, currentPrice, isSpot = false, pair }: Props) { +export function OrderConfigPanel({ state, dispatch, validation, currentPrice, isSpot = false, pair, strategies }: Props) { const d = dispatch as FieldDispatch; - const needsPrice = state.execution_strategy === "LIMIT" || state.execution_strategy === "LIMIT_MAKER"; - const isChaser = state.execution_strategy === "LIMIT_CHASER"; + const options = strategies + ? STRATEGY_OPTIONS.filter((o) => strategies.includes(o.value)) + : STRATEGY_OPTIONS; + + // A strategy carried over from another venue (LIMIT on binance → solana) has to + // fall back, or the panel would submit a strategy the venue cannot honor. + const allowed = options.some((o) => o.value === state.execution_strategy); + useEffect(() => { + if (!allowed) { + d({ type: "SET_FIELD", field: "execution_strategy", value: "MARKET" }); + } + }, [allowed]); // eslint-disable-line react-hooks/exhaustive-deps + + const needsPrice = + allowed && + (state.execution_strategy === "LIMIT" || state.execution_strategy === "LIMIT_MAKER"); + const isChaser = allowed && state.execution_strategy === "LIMIT_CHASER"; return (
@@ -207,7 +227,7 @@ export function OrderConfigPanel({ state, dispatch, validation, currentPrice, is value={state.execution_strategy} field="execution_strategy" dispatch={d} - options={STRATEGY_OPTIONS} + options={options} /> {!isSpot && ( diff --git a/frontend/src/components/market/ExchangeSelector.tsx b/frontend/src/components/market/ExchangeSelector.tsx index 344b9d7d..6eac9957 100644 --- a/frontend/src/components/market/ExchangeSelector.tsx +++ b/frontend/src/components/market/ExchangeSelector.tsx @@ -1,14 +1,33 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { ChevronDown } from "lucide-react"; interface ExchangeSelectorProps { connectors: string[]; value: string; onChange: (v: string) => void; + /** Connectors that are gateway DEX networks — listed under their own heading. */ + dexConnectors?: string[]; +} + +/** + * Format a gateway network id the way the Telegram surface does, so the two read + * alike: `solana-mainnet-beta` → `Solana`, `solana-devnet` → `Solana Dev`. + * Mirrors `utils/telegram_formatters.format_network_display`. + */ +function formatNetwork(id: string) { + const parts = id.split("-"); + const chain = parts[0].charAt(0).toUpperCase() + parts[0].slice(1); + if (parts.length === 1) return chain; + const net = parts[1]; + if (net === "mainnet") return chain; + if (net === "devnet") return `${chain} Dev`; + if (net === "testnet") return `${chain} Test`; + return `${chain} ${net.slice(0, 4)}`; } // Format connector name for display (e.g. "binance_perpetual" -> "Binance Perp") -function formatName(name: string) { +function formatName(name: string, isDex = false) { + if (isDex) return formatNetwork(name); return name .replace(/_perpetual$/, " perp") .replace(/_/g, " ") @@ -19,9 +38,13 @@ export function ExchangeSelector({ connectors, value, onChange, + dexConnectors = [], }: ExchangeSelectorProps) { const [open, setOpen] = useState(false); const ref = useRef(null); + const dexSet = new Set(dexConnectors); + const cexList = connectors.filter((c) => !dexSet.has(c)); + const dexList = connectors.filter((c) => dexSet.has(c)); useEffect(() => { if (!open) return; @@ -38,25 +61,21 @@ export function ExchangeSelector({ onClick={() => setOpen(!open)} className="flex items-center gap-1.5 px-3 py-2.5 text-xs transition-colors hover:bg-[var(--color-surface-hover)]" > - {formatName(value)} + {formatName(value, dexSet.has(value))} {open && (
- {connectors.map((c) => ( - + {/* Headings only earn their space once there is more than one group. */} + {dexList.length > 0 && cexList.length > 0 && CEX} + {cexList.map((c) => ( + setOpen(false)} /> + ))} + {dexList.length > 0 && cexList.length > 0 && DEX} + {dexList.map((c) => ( + setOpen(false)} /> ))}
@@ -64,3 +83,38 @@ export function ExchangeSelector({
); } + +function GroupHeading({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function ConnectorOption({ + name, + value, + isDex = false, + onSelect, + onClose, +}: { + name: string; + value: string; + isDex?: boolean; + onSelect: (v: string) => void; + onClose: () => void; +}) { + return ( + + ); +} diff --git a/frontend/src/components/market/PairSelector.tsx b/frontend/src/components/market/PairSelector.tsx index 511ecc5b..9e6cb436 100644 --- a/frontend/src/components/market/PairSelector.tsx +++ b/frontend/src/components/market/PairSelector.tsx @@ -11,15 +11,65 @@ interface PairSelectorProps { connector: string; value: string; onChange: (pair: string) => void; + /** + * Whether `/market/trading-rules` answers for this connector. False for gateway + * DEX networks, which have no pair list at all — the selector becomes free-text + * entry backed by a recents list. + */ + hasTradingRules?: boolean; } const MAX_VISIBLE = 50; +/** Recently-entered DEX pairs, per network. */ +const DEX_PAIRS_KEY = "condor_dex_pairs"; +const MAX_DEX_RECENTS = 12; + +function loadDexRecents(connector: string): string[] { + try { + const raw = localStorage.getItem(`${DEX_PAIRS_KEY}:${connector}`); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter((p) => typeof p === "string") : []; + } catch { + return []; + } +} + +// An EVM 0x-address or a base58 Solana pubkey. A DEX pair may carry a raw mint as +// its base (`-SOL`) — base58 is case-sensitive, so such a side must survive +// normalization untouched. Mirrors ADDRESS_RE in condor/dex_candles.py. +const ADDRESS_RE = /^(0x[0-9a-fA-F]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/; + +/** `sol-usdc` → `SOL-USDC`, leaving an address side exactly as typed. */ +function normalizeDexPair(input: string): string { + const trimmed = input.trim(); + const dash = trimmed.lastIndexOf("-"); + if (dash <= 0) return ADDRESS_RE.test(trimmed) ? trimmed : trimmed.toUpperCase(); + const base = trimmed.slice(0, dash); + const quote = trimmed.slice(dash + 1); + const up = (side: string) => + ADDRESS_RE.test(side) ? side : side.toUpperCase(); + return `${up(base)}-${up(quote)}`; +} + +function rememberDexPair(connector: string, pair: string) { + try { + const next = [pair, ...loadDexRecents(connector).filter((p) => p !== pair)].slice( + 0, + MAX_DEX_RECENTS, + ); + localStorage.setItem(`${DEX_PAIRS_KEY}:${connector}`, JSON.stringify(next)); + } catch { + /* ok */ + } +} + export function PairSelector({ server, connector, value, onChange, + hasTradingRules = true, }: PairSelectorProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -31,11 +81,15 @@ export function PairSelector({ const { data: rulesData, isLoading } = useQuery({ queryKey: ["trading-rules", server, connector], queryFn: () => api.getTradingRules(server, connector), - enabled: !!server && !!connector, + enabled: !!server && !!connector && hasTradingRules, staleTime: 5 * 60 * 1000, }); - const { byPair, rankByPair, hasTickers } = useTickers(server, connector); + const { byPair, rankByPair, hasTickers } = useTickers( + server, + connector, + hasTradingRules, + ); // Tradable pairs come from trading rules; tickers only decide the order and the // volume badge, so the selector still works on servers without /market-data/tickers. @@ -121,6 +175,11 @@ export function PairSelector({ } }; + // A gateway network has no pair list to offer: the user types the pair. + if (!hasTradingRules) { + return ; + } + // Fallback to plain text input if no rules available if (!isLoading && pairs.length === 0) { return ( @@ -239,12 +298,144 @@ export function PairSelector({ ); } +/** + * Pair entry for a gateway DEX network. + * + * There is no tradable-pair list to browse — Gateway resolves whatever the user + * names — so this is free-text `BASE-QUOTE` entry, the same grammar the Telegram + * DEX flow uses, with the pairs already tried on this network kept as shortcuts. + * A raw mint address is a valid base (`-SOL`); `_resolve_pool` expects + * exactly that shape and `PairLabel` renders it back as a ticker. + */ +function DexPairEntry({ + connector, + value, + onChange, +}: { + connector: string; + value: string; + onChange: (pair: string) => void; +}) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + const [recents, setRecents] = useState(() => loadDexRecents(connector)); + + useEffect(() => { + setRecents(loadDexRecents(connector)); + }, [connector]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + useEffect(() => { + if (open) { + setDraft(value); + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [open, value]); + + const commit = (raw: string) => { + const pair = normalizeDexPair(raw); + if (!pair) return; + rememberDexPair(connector, pair); + setRecents(loadDexRecents(connector)); + onChange(pair); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(draft); + } else if (e.key === "Escape") { + setOpen(false); + } + }} + placeholder="SOL-USDC or -SOL" + spellCheck={false} + autoComplete="off" + className="flex-1 bg-transparent text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + +
+ + {recents.length > 0 && ( +
+

+ Recent +

+ {recents.map((p) => ( + + ))} +
+ )} + +

+ Gateway resolves the pool from the pair — a token mint works as the base. +

+
+ )} +
+ ); +} + // Export the rules map hook for TradingRulesInfo -export function useTradingRules(server: string, connector: string) { +export function useTradingRules(server: string, connector: string, enabled = true) { const { data } = useQuery({ queryKey: ["trading-rules", server, connector], queryFn: () => api.getTradingRules(server, connector), - enabled: !!server && !!connector, + enabled: !!server && !!connector && enabled, staleTime: 5 * 60 * 1000, }); return data; diff --git a/frontend/src/components/market/PriceTicker.tsx b/frontend/src/components/market/PriceTicker.tsx index f09f4188..1fcb38eb 100644 --- a/frontend/src/components/market/PriceTicker.tsx +++ b/frontend/src/components/market/PriceTicker.tsx @@ -10,9 +10,15 @@ interface PriceTickerProps { pair: string; /** Candle interval to track — defaults to "1m" for most responsive updates */ interval?: string; + /** + * Whether `/market/prices` answers for this connector. False for gateway DEX + * networks, where the candle close is the only price and the REST call would + * only 502; bid/ask/spread are then simply absent. + */ + hasRestPrice?: boolean; } -export function PriceTicker({ server, connector, pair, interval = "1m" }: PriceTickerProps) { +export function PriceTicker({ server, connector, pair, interval = "1m", hasRestPrice = true }: PriceTickerProps) { const prevPriceRef = useRef(0); const [candlePrice, setCandlePrice] = useState(0); @@ -47,7 +53,7 @@ export function PriceTicker({ server, connector, pair, interval = "1m" }: PriceT const { data: price } = useQuery({ queryKey: ["price", server, connector, pair], queryFn: () => api.getPrice(server, connector, pair), - enabled: !!server && !!connector && !!pair, + enabled: !!server && !!connector && !!pair && hasRestPrice, refetchInterval: 15_000, }); diff --git a/frontend/src/components/market/useTickers.ts b/frontend/src/components/market/useTickers.ts index 8dacb26e..1d6ec6e1 100644 --- a/frontend/src/components/market/useTickers.ts +++ b/frontend/src/components/market/useTickers.ts @@ -6,12 +6,15 @@ import { api, type Ticker } from "@/lib/api"; /** * 24h tickers for a connector, already sorted by USD volume (highest first) by the * backend. Shared by PairSelector and MarketsPanel so both hit one cached query. + * + * @param enabled Pass false for venues with no ticker endpoint (gateway DEX + * networks), where the request would only 502. */ -export function useTickers(server: string, connector: string) { +export function useTickers(server: string, connector: string, enabled = true) { const { data, isLoading, isFetching } = useQuery({ queryKey: ["tickers", server, connector], queryFn: () => api.getTickers(server, connector), - enabled: !!server && !!connector, + enabled: !!server && !!connector && enabled, staleTime: 60 * 1000, refetchInterval: 60 * 1000, }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 409922fc..0d9d9bb4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1179,6 +1179,12 @@ export const api = { getConnectedExchanges: (server: string) => apiFetch(`/api/v1/servers/${encodeURIComponent(server)}/market/connected-exchanges`), + /** Chartable gateway DEX networks — the trade panel's DEX half of the dropdown. */ + getGatewayNetworks: (server: string) => + apiFetch<{ networks: string[] }>( + `/api/v1/servers/${encodeURIComponent(server)}/market/gateway-networks`, + ).then((r) => r.networks), + getPrice: (server: string, connector: string, pair: string) => apiFetch( `/api/v1/servers/${encodeURIComponent(server)}/market/prices?connector=${encodeURIComponent(connector)}&trading_pair=${encodeURIComponent(pair)}`, diff --git a/frontend/src/lib/connector-capabilities.ts b/frontend/src/lib/connector-capabilities.ts new file mode 100644 index 00000000..4998f770 --- /dev/null +++ b/frontend/src/lib/connector-capabilities.ts @@ -0,0 +1,74 @@ +import type { ExecutorType } from "@/components/executor/types"; + +/** + * What a connector can do in the trade panel. + * + * DEX-ness is **not** guessed from the name here. The backend already carries four + * mutually inconsistent connector predicates (prefix lists, substring lists, live + * gateway lookups) that disagree on cases like `binance-smart-chain`; a fifth one + * in the frontend would silently drift the same way, and the symptom would be "the + * chart is blank and Grid is offered on a DEX". Instead the server tells us which + * connectors are gateway networks (`GET /market/gateway-networks`, already + * intersected with the set Condor can chart) and this module classifies by + * membership in that list. + * + * The executor-type and strategy maps below *are* frontend literals, deliberately: + * they are a UI product decision about which tabs and options to render, not a + * server fact, and they are changed by whoever changes the tabs. + */ + +export type ConnectorKind = "cex" | "dex"; + +/** + * An executor type a *venue* supports, which is not yet the same set as the tabs + * the panel implements: `"lp"` has no entry in `TYPE_TABS` until FEAT-042 adds + * one. Keeping it out of `ExecutorType` leaves that union meaning "a tab this + * panel can render", so the exhaustive switches over it stay exhaustive. + */ +export type SupportedExecutorType = ExecutorType | "lp"; + +export interface ConnectorCapabilities { + kind: ConnectorKind; + /** Executor types this venue supports. */ + executorTypes: SupportedExecutorType[]; + /** Allowed `execution_strategy` values (subset of OrderConfigPanel's options). */ + orderStrategies: string[]; + /** Whether `/market/order-book` and `/market/tickers` answer — depth + markets tabs. */ + hasOrderBook: boolean; + /** Whether `/market/trading-rules` answers — pair list + price precision. */ + hasTradingRules: boolean; +} + +const CEX_CAPABILITIES: ConnectorCapabilities = { + kind: "cex", + executorTypes: ["order", "position", "grid", "dca"], + orderStrategies: ["MARKET", "LIMIT", "LIMIT_MAKER", "LIMIT_CHASER"], + hasOrderBook: true, + hasTradingRules: true, +}; + +// A gateway swap has no resting order book to post to, so MARKET is the only +// execution strategy that means anything. `lp` is listed from day one; the tab +// list filters against TYPE_TABS, so it simply finds no entry until FEAT-042 +// adds one. +const DEX_CAPABILITIES: ConnectorCapabilities = { + kind: "dex", + executorTypes: ["order", "lp"], + orderStrategies: ["MARKET"], + hasOrderBook: false, + hasTradingRules: false, +}; + +/** + * Capabilities of `connector`, given the gateway networks this server exposes. + * + * An unknown connector — or any connector at all before the gateway-networks + * query resolves — is treated as a CEX, which is the status quo behavior for + * every venue the panel handled before DEX support existed. + */ +export function connectorCapabilities( + connector: string, + gatewayNetworks: string[], +): ConnectorCapabilities { + return gatewayNetworks.includes(connector) ? DEX_CAPABILITIES : CEX_CAPABILITIES; +} diff --git a/frontend/src/pages/CreateExecutor.tsx b/frontend/src/pages/CreateExecutor.tsx index 9cf3a6e4..51e925a6 100644 --- a/frontend/src/pages/CreateExecutor.tsx +++ b/frontend/src/pages/CreateExecutor.tsx @@ -29,6 +29,7 @@ import { PositionConfigPanel, usePositionConfig } from "@/components/executor/Po import { OrderConfigPanel, useOrderConfig } from "@/components/executor/OrderConfigPanel"; import { DCAConfigPanel, useDCAConfig } from "@/components/executor/DCAConfigPanel"; import { TradeBottomPane } from "@/components/trade/TradeBottomPane"; +import { useCandleStore } from "@/hooks/useCandleStore"; import { useServer } from "@/hooks/useServer"; import { useCondorWebSocket } from "@/hooks/useWebSocket"; import { useMainControllerData } from "@/hooks/useMainControllerData"; @@ -36,6 +37,7 @@ import { useRates } from "@/hooks/useRates"; import { useResizeDrag } from "@/hooks/useResizeDrag"; import { api } from "@/lib/api"; import { candleStore } from "@/lib/candle-store"; +import { connectorCapabilities } from "@/lib/connector-capabilities"; import type { ExecutorType } from "@/components/executor/types"; import { gridReducer, @@ -141,12 +143,38 @@ export function CreateExecutor() { cursor: "row-resize", }); - const { data: connectors = [] } = useQuery({ + const { data: connectors = [], isPending: connectorsPending } = useQuery({ queryKey: ["connected-exchanges", server], queryFn: () => api.getConnectedExchanges(server!), enabled: !!server, }); + // `connected-exchanges` is CEX-only by contract and has other callers, so the + // gateway networks arrive on their own endpoint and the panel merges the two. + const { data: gatewayNetworks = [], isPending: networksPending } = useQuery({ + queryKey: ["gateway-networks", server], + queryFn: () => api.getGatewayNetworks(server!), + enabled: !!server, + staleTime: 5 * 60 * 1000, + }); + + // Both halves of the dropdown have to be in before the panel may *correct* a + // selection: judging a persisted DEX network against the CEX list alone would + // bounce it to connectors[0] on every reload, and switch its tab to Order. + const listsReady = !!server && !connectorsPending && !networksPending; + + // Deduplicated: `is_cex_connector` still calls `binance-smart-chain` a CEX, so a + // network can legitimately appear in both lists. + const allConnectors = useMemo( + () => [...new Set([...connectors, ...gatewayNetworks])], + [connectors, gatewayNetworks], + ); + + const caps = useMemo( + () => connectorCapabilities(connector, gatewayNetworks), + [connector, gatewayNetworks], + ); + // WS for executor data (candle streams are managed by candleStore) const wsChannels = useMemo( () => server ? [`executors:${server}`] : [], @@ -158,7 +186,7 @@ export function CreateExecutor() { const { executors: mainExecutors, overlays: mainOverlays, positions: mainPositions, isLoadingPositions } = useMainControllerData(server, connector, pair); - const rulesData = useTradingRules(server ?? "", connector); + const rulesData = useTradingRules(server ?? "", connector, caps.hasTradingRules); // Currency conversion for chart tooltip values const quoteCurrency = pair.split("-")[1] || "USDT"; @@ -181,12 +209,21 @@ export function CreateExecutor() { setSelectedExecutorId(null); }, [connector, pair]); - // Sync connector to filtered list + // Sync connector to the merged list. Validating against the CEX list alone would + // bounce a selected DEX network back to connectors[0] on every render. + useEffect(() => { + if (listsReady && allConnectors.length && !allConnectors.includes(connector)) { + gridDispatch({ type: "SET_CONNECTOR", value: allConnectors[0] }); + } + }, [listsReady, allConnectors, connector]); + + // Executor types the venue does not support cannot stay selected (Grid on a CEX + // → pick a DEX → land on Order). useEffect(() => { - if (connectors.length && !connectors.includes(connector)) { - gridDispatch({ type: "SET_CONNECTOR", value: connectors[0] }); + if (listsReady && !caps.executorTypes.includes(executorType)) { + handleTypeChange("order"); } - }, [connectors, connector]); + }, [listsReady, caps, executorType]); // eslint-disable-line react-hooks/exhaustive-deps // Reset pair when connector changes useEffect(() => { @@ -212,15 +249,28 @@ export function CreateExecutor() { dcaConfig.dispatch({ type: "SET_PAIR", value: pair }); }, [pair]); // eslint-disable-line react-hooks/exhaustive-deps - // Current price + // Current price. /market/prices is a Hummingbot API call with no DEX answer, so a + // gateway network reads the last close off the candle stream instead. The store is + // a singleton over one shared channel and TradeChart already subscribes with these + // exact arguments, so this costs no extra connection. const { data: priceData } = useQuery({ queryKey: ["price", server, connector, pair], queryFn: () => api.getPrice(server!, connector, pair), - enabled: !!server && !!connector && !!pair, + enabled: !!server && !!connector && !!pair && caps.kind === "cex", refetchInterval: 5000, }); - const currentPrice = priceData?.mid_price ?? null; + const { candles: sharedCandles } = useCandleStore( + server ?? null, + connector, + pair, + gridState.interval, + ); + + const currentPrice = + caps.kind === "dex" + ? (sharedCandles[sharedCandles.length - 1]?.close ?? null) + : (priceData?.mid_price ?? null); // Price precision const pricePrecision = useMemo(() => { @@ -232,6 +282,10 @@ export function CreateExecutor() { return Math.max(0, Math.ceil(-Math.log10(inc))); }, [rulesData, pair]); + // Depth and Markets have no DEX answer. Derived rather than reset in an effect, so + // a CEX selection is remembered and comes back when the user returns to a CEX. + const activePanel = caps.hasOrderBook ? rightPanel : "config"; + // ── Active config derived values ── const activeValidation = useMemo(() => { switch (executorType) { @@ -372,19 +426,21 @@ export function CreateExecutor() { connector={connector} value={pair} onChange={(v) => gridDispatch({ type: "SET_PAIR", value: v })} + hasTradingRules={caps.hasTradingRules} />
gridDispatch({ type: "SET_CONNECTOR", value: v })} + dexConnectors={gatewayNetworks} />
{/* Price ticker */}
- +
{/* Interval + Range */} @@ -505,7 +561,7 @@ export function CreateExecutor() { + {caps.hasOrderBook && ( + )} + {caps.hasOrderBook && ( + )} - {rightPanel === "config" ? ( + {activePanel === "config" ? ( <> {/* Type Tabs */}
- {TYPE_TABS.map((tab) => ( + {TYPE_TABS.filter((t) => caps.executorTypes.includes(t.value)).map((tab) => (
- ) : rightPanel === "depth" ? ( + ) : activePanel === "depth" ? ( ) : ( Date: Tue, 11 Aug 2026 09:37:53 +0300 Subject: [PATCH 003/116] Resolve the pool a DEX pair trades in, and whether it can take an LP position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LP panel has to name a pool and a `dex/clmm` provider before it can build an `lp_executor` config, and the chart already picks a pool for every DEX candle it draws. Resolving them by two mechanisms would let the panel and the chart disagree silently, so `fetch_token_top_pool` — which threw away everything but the address because that is all the candle path needs — becomes the address form of `fetch_token_top_pool_info`, sharing its cache entry. Same for the symbol-search branch. The candle path calls the same functions and cannot tell the difference. `lp_provider_for_dex` refuses to guess, because a wrong provider is not a wrong label but a failed executor. It matches on (brand, product) rather than brand, since GeckoTerminal's dex ids are not a stable vocabulary: Uniswap V3 arrives as `uniswap_v3`, `uniswap-v3-base` and `uniswap_v3_arbitrum`, while `uniswap-v4-ethereum`, `meteora-damm-v2` and plain `raydium` share a brand with a supported venue but not its position model. Every id in the test table was observed coming back from the live API. `can_fetch_liquidity` is deliberately not the gate the design named: it compares a gecko network id against a chain *name*, so it is false for every Uniswap pool on Ethereum, and it has no notion of Uniswap on Arbitrum or Base at all. It keeps answering its own question (can Telegram draw the liquidity-bin chart) untouched. `GET /market/dex-pool` reports the dead end rather than hiding it — a 200 with `lp_supported: false` when the deepest pool is a router or plain AMM, which is the common case for pairs like JUP-USDC. The panel's answer is to ask for a pool address by hand, and that is a state to render, not an error to handle. FEAT-042 --- condor/web/routes/market.py | 67 +++++ handlers/dex/pool_data.py | 251 ++++++++++++++-- tests/test_dex_pool_resolution.py | 475 ++++++++++++++++++++++++++++++ 3 files changed, 762 insertions(+), 31 deletions(-) create mode 100644 tests/test_dex_pool_resolution.py diff --git a/condor/web/routes/market.py b/condor/web/routes/market.py index 428ec3ea..a3c2def6 100644 --- a/condor/web/routes/market.py +++ b/condor/web/routes/market.py @@ -127,6 +127,73 @@ async def get_gateway_networks(name: str, user: WebUser = Depends(get_current_us return {"networks": result or []} +_NO_POOL = { + "pool_address": None, + "dex_id": None, + "lp_provider": None, + "lp_supported": False, + "current_price": None, + "base_symbol": None, + "quote_symbol": None, +} + + +@router.get("/servers/{name}/market/dex-pool") +async def get_dex_pool( + name: str, + connector: str = Query( + ..., description="Gateway network, e.g. solana-mainnet-beta" + ), + trading_pair: str = Query(...), + user: WebUser = Depends(get_current_user), +): + """The pool a DEX pair trades in, and whether an LP position can be opened in it. + + Makes explicit the choice the chart already makes implicitly: candles for a DEX + pair come from whichever pool ``resolve_pool_info`` picks, and the LP panel has + to name that same pool. ``lp_provider`` is the ``dex/trading_type`` string an + ``lp_executor`` requires, and is ``None`` with ``lp_supported: false`` whenever + the deepest pool is not on one of the five CLMM venues — a router pool, a plain + AMM, or Uniswap v4. That is a 200, not an error: the panel's answer to it is to + ask for a pool address by hand, which is a state to render rather than a failure + to handle. + """ + cm = get_config_manager() + if not cm.has_server_access(user.id, name): + raise HTTPException(status_code=403, detail="No access") + + from handlers.dex.pool_data import lp_provider_for_dex, resolve_pool_info + + try: + info = await resolve_pool_info(connector, trading_pair) + except Exception as e: + logger.warning( + "Pool resolution failed connector=%s pair=%s: %s", + connector, + trading_pair, + e, + ) + return dict(_NO_POOL) + + address = str((info or {}).get("address") or "") + # A resolved address that does not look like one would be handed straight back + # to /market/candles and the executor config, so it is refused here instead. + if not info or not _POOL_ADDRESS_RE.match(address): + return dict(_NO_POOL) + + dex_id = str(info.get("dex_id") or "") + provider = lp_provider_for_dex(dex_id, connector) + return { + "pool_address": address, + "dex_id": dex_id or None, + "lp_provider": provider, + "lp_supported": provider is not None, + "current_price": info.get("current_price"), + "base_symbol": info.get("base_symbol"), + "quote_symbol": info.get("quote_symbol"), + } + + @router.get("/servers/{name}/market/prices", response_model=MarketPriceResponse) async def get_price( name: str, diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py index 80d522eb..3c31ba64 100644 --- a/handlers/dex/pool_data.py +++ b/handlers/dex/pool_data.py @@ -14,6 +14,7 @@ import logging import math +import re import time from typing import Any, Dict, List, Optional, Tuple @@ -25,6 +26,39 @@ logger = logging.getLogger(__name__) +# CLMM venues an ``lp_executor`` can open a position on: (brand, the qualifiers its +# GeckoTerminal dex id must carry, the gecko networks it exists on). Deliberately +# separate from LIQUIDITY_SUPPORTED_DEXES below, which answers a different question +# (can Condor draw Telegram's liquidity-bin chart) and compares gecko network ids +# against chain *names*, so it is false for every Uniswap pool on Ethereum. +_CLMM_VENUES: Tuple[Tuple[str, List[str], set], ...] = ( + # Gecko's `meteora` is DLMM and `orca` is Whirlpools — both already the + # concentrated product, so any qualifier means a different pool type. + ("meteora", [], {"solana"}), + ("orca", [], {"solana"}), + # Plain `raydium` is the constant-product AMM v4; only `raydium-clmm` is CLMM. + ("raydium", ["clmm"], {"solana"}), + ("uniswap", ["v3"], {"eth", "arbitrum", "base", "polygon_pos", "optimism", "bsc"}), + ("pancakeswap", ["v3"], {"bsc", "eth", "base", "arbitrum"}), +) + +# Chain suffixes gecko tacks onto a dex id (`uniswap-v3-base`). They say where the +# venue is, not which product it is, so they are dropped before matching. +_CHAIN_TOKENS = { + "solana", + "ethereum", + "eth", + "base", + "arbitrum", + "bsc", + "polygon", + "pos", + "optimism", + "avalanche", +} + +_DEX_ID_SPLIT_RE = re.compile(r"[-_]") + # Supported DEXes for liquidity data (via gateway CLMM) LIQUIDITY_SUPPORTED_DEXES = { "meteora": "solana", @@ -171,8 +205,14 @@ def _gecko_client() -> GeckoTerminalAsyncClient: # cannot grow them without limit. _TOKEN_CACHE_MAX = 512 _token_symbol_cache: Dict[Tuple[str, str], Tuple[float, str]] = {} -_token_pool_cache: Dict[Tuple[str, str, str], Tuple[float, str]] = {} -_pair_pool_cache: Dict[Tuple[str, str, str], Tuple[float, Tuple[str, bool]]] = {} +# Both hold normalized pool dicts, and an empty dict for "asked, there is no such +# pool" — a real answer worth caching, unlike a failed lookup, which is not cached +# at all. The address-only forms (fetch_token_top_pool, fetch_pair_top_pool) read +# these same entries. +_token_pool_cache: Dict[Tuple[str, str, str], Tuple[float, Dict[str, Any]]] = {} +_pair_pool_cache: Dict[ + Tuple[str, str, str], Tuple[float, Tuple[Dict[str, Any], bool]] +] = {} def _ttl_get(cache: dict, key: tuple, ttl: float) -> Optional[Any]: @@ -223,22 +263,27 @@ def _pool_quote_symbols(name: Any) -> List[str]: return [p.strip().upper() for p in str(name or "").split("/") if p.strip()] -async def fetch_token_top_pool(mint: str, network: str, quote: str) -> str: - """Address of the token's highest-volume pool **quoted in ``quote``**. +async def fetch_token_top_pool_info( + mint: str, network: str, quote: str +) -> Optional[Dict[str, Any]]: + """The token's highest-volume pool **quoted in ``quote``**, normalized. - Used as a fallback when an executor's own ``pool_address`` yields no candles - (a closed slot, a pool that never had one recorded). The quote match is not - cosmetic: candles are requested with ``currency="token"``, so prices come back - denominated in the pool's quote token. Charting a token/USDC pool underneath a - token/SOL position would silently draw the right shape on the wrong scale, so - a pool that does not match returns "" and the chart stays empty instead. + The quote match is not cosmetic: candles are requested with ``currency="token"``, + so prices come back denominated in the pool's quote token. Charting a token/USDC + pool underneath a token/SOL position would silently draw the right shape on the + wrong scale, so a pool that does not match yields ``None`` and the chart stays + empty instead. + + ``None`` distinguishes "no answer yet" from "asked and there is none": a + no-match *is* cached (as ``{}``), a failed lookup is not, so a GeckoTerminal + blip cannot pin an empty answer for the cache's full hour. """ gnet = get_gecko_network(network) want = (quote or "").strip().upper() key = (gnet, mint, want) cached = _ttl_get(_token_pool_cache, key, TOKEN_POOL_TTL) if cached is not None: - return cached + return cached or None try: pools = await _gecko_client().get_top_pools_by_network_token(gnet, mint) @@ -246,26 +291,42 @@ async def fetch_token_top_pool(mint: str, network: str, quote: str) -> str: # Includes the KeyError geckoterminal_py raises post-processing an empty # result set. Not cached — a transient failure must not pin "" for an hour. logger.info("top-pool lookup failed mint=%s net=%s: %s", mint, gnet, e) - return "" + return None - address = "" + info: Dict[str, Any] = {} try: # Rows arrive sorted by 24h volume, so the first quote match is the deepest. for row in pools.to_dict("records"): if want and want not in _pool_quote_symbols(row.get("name")): continue - address = str(row.get("address") or "") - if address: - break + if not str(row.get("address") or ""): + continue + info = normalize_pool_data(row, source="gecko") + _fill_pool_pair_fields(info, network, row) + break except Exception as e: logger.info("top-pool parse failed mint=%s net=%s: %s", mint, gnet, e) - return "" + return None - _ttl_put(_token_pool_cache, key, address, TOKEN_POOL_TTL) - return address + _ttl_put(_token_pool_cache, key, info, TOKEN_POOL_TTL) + return info or None -async def fetch_pair_top_pool(base: str, quote: str, network: str) -> Tuple[str, bool]: +async def fetch_token_top_pool(mint: str, network: str, quote: str) -> str: + """Address of the token's highest-volume pool **quoted in ``quote``**. + + Used as a fallback when an executor's own ``pool_address`` yields no candles + (a closed slot, a pool that never had one recorded). The address form of + :func:`fetch_token_top_pool_info`, which is all the candle path needs; the LP + panel wants the venue and price too and shares this function's cache entry. + """ + info = await fetch_token_top_pool_info(mint, network, quote) + return str((info or {}).get("address") or "") + + +async def fetch_pair_top_pool_info( + base: str, quote: str, network: str +) -> Tuple[Optional[Dict[str, Any]], bool]: """Deepest pool trading ``base``/``quote`` on ``network``, found by *symbol*. For venues that quote tickers rather than addresses (see @@ -276,21 +337,22 @@ async def fetch_pair_top_pool(base: str, quote: str, network: str) -> Tuple[str, GeckoTerminal UI shows first. Returns: - ``(pool_address, inverted)``. ``inverted`` is True when the pool is quoted + ``(pool_info, inverted)``. ``inverted`` is True when the pool is quoted the other way round (pair ``XRP-RLUSD`` against a ``RLUSD / XRP`` pool); - the caller must then read the quote token's price series, not the base's. - ``("", False)`` when nothing matches — an empty chart beats a chart drawn - on the wrong pair. + the caller must then read the quote token's price series, not the base's, + and ``pool_info``'s pair fields are reported as the *caller* asked for + them, not as the pool names them. ``(None, False)`` when nothing matches — + an empty chart beats a chart drawn on the wrong pair. """ gnet = get_gecko_network(network) b, q = (base or "").strip().upper(), (quote or "").strip().upper() if not b or not q: - return "", False + return None, False key = (gnet, b, q) cached = _ttl_get(_pair_pool_cache, key, TOKEN_POOL_TTL) if cached is not None: - return cached + return (cached[0] or None), cached[1] try: # Both symbols in the query: GeckoTerminal matches them against the pool @@ -301,9 +363,9 @@ async def fetch_pair_top_pool(base: str, quote: str, network: str) -> Tuple[str, except Exception as e: # Not cached — a blip must not pin "no pool" for the full hour. logger.info("pair-pool search failed %s-%s net=%s: %s", b, q, gnet, e) - return "", False + return None, False - best: Tuple[str, bool] = ("", False) + best: Tuple[Dict[str, Any], bool] = ({}, False) best_volume = -1.0 try: for row in data.get("data") or []: @@ -322,13 +384,140 @@ async def fetch_pair_top_pool(base: str, quote: str, network: str) -> Tuple[str, continue volume = _get_nested_float(attrs, "volume_usd", "h24") or 0.0 if volume > best_volume: - best, best_volume = (address, inverted), volume + flat = _flatten_search_pool(row) + info = normalize_pool_data(flat, source="gecko") + _fill_pool_pair_fields( + info, network, flat["attributes"], inverted=inverted + ) + best, best_volume = (info, inverted), volume except Exception as e: logger.info("pair-pool parse failed %s-%s net=%s: %s", b, q, gnet, e) - return "", False + return None, False _ttl_put(_pair_pool_cache, key, best, TOKEN_POOL_TTL) - return best + return (best[0] or None), best[1] + + +async def fetch_pair_top_pool(base: str, quote: str, network: str) -> Tuple[str, bool]: + """``(pool_address, inverted)`` for a symbol-quoted pair. + + The address form of :func:`fetch_pair_top_pool_info`, which is all the candle + path needs; both share the same cache entry. + """ + info, inverted = await fetch_pair_top_pool_info(base, quote, network) + return str((info or {}).get("address") or ""), inverted + + +def _flatten_search_pool(row: Dict[str, Any]) -> Dict[str, Any]: + """Lift a ``search/pools`` row's venue onto its attributes. + + The top-pools *token* endpoint carries ``dex_id`` as a column, but + ``search/pools`` puts the venue in ``relationships.dex.data.id`` instead. The + LP panel's whole question is which venue the pool is on, so the two shapes have + to agree before :func:`normalize_pool_data` sees them. + """ + attrs = dict(row.get("attributes") or {}) + if not attrs.get("dex_id"): + dex = ((row.get("relationships") or {}).get("dex") or {}).get("data") or {} + attrs["dex_id"] = str(dex.get("id") or "unknown") + return {"id": row.get("id", ""), "attributes": attrs} + + +def _fill_pool_pair_fields( + info: Dict[str, Any], + network: str, + attrs: Dict[str, Any], + inverted: bool = False, +) -> None: + """Add the pair fields the LP panel needs, in place. + + GeckoTerminal reports no token *symbol* on a pool row (only the ``"SOL / USDC"`` + display name) and prices in USD or as a quote-token ratio depending on the + endpoint — and ``normalize_pool_data`` keeps neither ratio, hence ``attrs``, the + row it was built from. This fills ``base_symbol`` / ``quote_symbol`` / + ``current_price`` — base priced in quote, the scale every LP bound is expressed + in — and pins ``network`` to what the caller asked for rather than + ``normalize_pool_data``'s ``"solana"`` default. + """ + base_sym, quote_sym = extract_pair_from_name(str(info.get("name") or "")) + ratio = _get_nested_float(attrs, "base_token_price_quote_token") + if ratio is None: + base_usd = _get_nested_float(attrs, "base_token_price_usd") + quote_usd = _get_nested_float(attrs, "quote_token_price_usd") + if base_usd and quote_usd: + ratio = base_usd / quote_usd + + if inverted: + base_sym, quote_sym = quote_sym, base_sym + ratio = (1 / ratio) if ratio else None + + info["base_symbol"] = base_sym.upper() + info["quote_symbol"] = quote_sym.upper() + info["current_price"] = ratio + info["network"] = network + + +async def resolve_pool_info( + network: str, trading_pair: str +) -> Optional[Dict[str, Any]]: + """The pool a trading pair trades in, normalized, or ``None``. + + Mirrors ``condor.dex_candles._resolve_pool``'s branching — a base that *is* a + mint resolves through the token's top pools, a ticker base through a symbol + search — so the LP panel names the same pool the chart is drawn from rather + than resolving pools by a second mechanism. + + It differs in one direction only: ``_resolve_pool`` restricts the symbol + branch to ``SYMBOL_PAIR_NETWORKS``, so a ticker pair like ``SOL-USDC`` on + Solana resolves here but not there (the chart stays blank while the panel + still names a pool). Pointing ``_resolve_pool`` at this function would close + that gap; it is a change to the candle path and deliberately not made here. + """ + # Lazy, like dex_candles' own import of this module: one definition of what an + # on-chain address looks like, and no import cycle between the two. + from condor.dex_candles import ADDRESS_RE + + base, _, quote = str(trading_pair or "").partition("-") + base, quote = base.strip(), quote.strip() + if not base or not quote: + return None + + if ADDRESS_RE.match(base): + return await fetch_token_top_pool_info(base, network, quote) + + info, _inverted = await fetch_pair_top_pool_info(base, quote, network) + return info + + +def lp_provider_for_dex(dex_id: str, network: str) -> Optional[str]: + """``"meteora/clmm"``-style LP provider for a GeckoTerminal dex id, or ``None``. + + ``None`` means the pool is not one an ``lp_executor`` can add liquidity to, and + the caller is expected to report that dead end rather than guess: the API + rejects an ``lp_provider`` that does not match the pool, so a wrong answer here + becomes a failed executor. + + Matching is by (brand, product) because GeckoTerminal's dex ids are not a + stable vocabulary and brand alone is not the position model. Uniswap V3 is + ``uniswap_v3`` on Ethereum, ``uniswap-v3-base`` on Base and + ``uniswap_v3_arbitrum`` on Arbitrum; ``uniswap-v4-ethereum`` and + ``meteora-damm-v2`` share a brand with a supported venue but not its mechanics; + and plain ``raydium`` is the constant-product AMM, while only ``raydium-clmm`` + is the concentrated pool the ``raydium/clmm`` connector drives. + """ + tokens = [t for t in _DEX_ID_SPLIT_RE.split((dex_id or "").strip().lower()) if t] + if not tokens: + return None + brand, qualifiers = tokens[0], [t for t in tokens[1:] if t not in _CHAIN_TOKENS] + for name, required, networks in _CLMM_VENUES: + if brand != name: + continue + if qualifiers != required: + return None + if get_gecko_network(network) not in networks: + return None + return f"{name}/clmm" + return None def can_fetch_liquidity(dex_id: str, network: str = None) -> bool: diff --git a/tests/test_dex_pool_resolution.py b/tests/test_dex_pool_resolution.py new file mode 100644 index 00000000..ef4f97db --- /dev/null +++ b/tests/test_dex_pool_resolution.py @@ -0,0 +1,475 @@ +"""Tests for FEAT-042: pool resolution serves the LP panel and the candle path. + +Two things are being protected here. + +**The candle path must not notice the refactor.** ``fetch_token_top_pool`` used to +do the GeckoTerminal query itself; it is now the address form of +``fetch_token_top_pool_info``, and ``dex_candles._resolve_pool`` still calls the +former. Same address, same cache entry, same "a failure is not cached but an empty +answer is" behavior — ``tests/test_dex_candles.py`` covers the address contract, +these cover the delegation and the shared cache. + +**The LP panel cannot guess the venue.** An ``lp_executor`` needs +``lp_provider`` as ``dex/clmm``, and the API rejects anything else. The deepest pool +for a pair is frequently *not* a CLMM pool — ``meteora-damm-v2`` and +``uniswap-v4-ethereum`` are real answers from the live API — so +``lp_provider_for_dex`` has to say "no" rather than brand-match its way to a payload +that fails. +""" + +import asyncio + +import pytest + +from handlers.dex import pool_data + + +def run(coro): + return asyncio.run(coro) + + +class _FakeRows: + """Stands in for the DataFrame ``get_top_pools_by_network_token`` returns.""" + + def __init__(self, rows): + self._rows = rows + + def to_dict(self, _orient): + return self._rows + + +def _token_row(**over): + row = { + "name": "BONK / SOL", + "address": "bonk_sol_pool", + "dex_id": "meteora", + "base_token_price_usd": "0.000002", + "quote_token_price_usd": "100.0", + "volume_usd_h24": 1000.0, + } + row.update(over) + return row + + +def _search_row(name="SOL / USDC", address="sol_usdc_pool", dex="orca", **attrs): + base = { + "name": name, + "address": address, + "base_token_price_quote_token": "75.5", + "volume_usd": {"h24": 5000.0}, + } + base.update(attrs) + return { + "id": f"solana_{address}", + "attributes": base, + "relationships": {"dex": {"data": {"id": dex, "type": "dex"}}}, + } + + +@pytest.fixture(autouse=True) +def _clear_caches(): + caches = (pool_data._token_pool_cache, pool_data._pair_pool_cache) + for cache in caches: + cache.clear() + yield + for cache in caches: + cache.clear() + + +# ── The candle path is unchanged by the extraction ── + + +def test_the_address_form_still_returns_a_bare_address(monkeypatch): + class _Client: + async def get_top_pools_by_network_token(self, *_a): + return _FakeRows([_token_row()]) + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + assert ( + run(pool_data.fetch_token_top_pool("mint", "solana", "SOL")) == "bonk_sol_pool" + ) + + +def test_the_two_forms_share_one_cache_entry(monkeypatch): + """The candle path and the LP panel ask the same question — once.""" + calls = [] + + class _Client: + async def get_top_pools_by_network_token(self, *_a): + calls.append(1) + return _FakeRows([_token_row()]) + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + info = run(pool_data.fetch_token_top_pool_info("mint", "solana", "SOL")) + address = run(pool_data.fetch_token_top_pool("mint", "solana", "SOL")) + assert info["address"] == address == "bonk_sol_pool" + assert len(calls) == 1 + + +def test_a_no_match_is_cached_once_for_both_forms(monkeypatch): + """An unlisted pair is a real answer; re-asking on every render is not.""" + calls = [] + + class _Client: + async def get_top_pools_by_network_token(self, *_a): + calls.append(1) + return _FakeRows([_token_row(name="BONK / USDC")]) + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + assert run(pool_data.fetch_token_top_pool_info("mint", "solana", "SOL")) is None + assert run(pool_data.fetch_token_top_pool("mint", "solana", "SOL")) == "" + assert len(calls) == 1 + + +def test_a_failed_lookup_is_still_not_cached_in_the_info_form(monkeypatch): + calls = [] + + class _Failing: + async def get_top_pools_by_network_token(self, *_a): + calls.append(1) + raise RuntimeError("429 Too Many Requests") + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Failing()) + assert run(pool_data.fetch_token_top_pool_info("mint", "solana", "SOL")) is None + assert run(pool_data.fetch_token_top_pool_info("mint", "solana", "SOL")) is None + assert len(calls) == 2 + + +def test_a_row_without_an_address_is_skipped(monkeypatch): + """Preserved from the address form: a row with no address is not a pool.""" + + class _Client: + async def get_top_pools_by_network_token(self, *_a): + return _FakeRows([_token_row(address=""), _token_row(address="real_pool")]) + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + assert run(pool_data.fetch_token_top_pool("mint", "solana", "SOL")) == "real_pool" + + +# ── What the extraction adds ── + + +def test_the_info_form_carries_the_venue_and_the_pair_price(monkeypatch): + class _Client: + async def get_top_pools_by_network_token(self, *_a): + return _FakeRows([_token_row()]) + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + info = run( + pool_data.fetch_token_top_pool_info("mint", "solana-mainnet-beta", "SOL") + ) + assert info["dex_id"] == "meteora" + assert info["base_symbol"] == "BONK" + assert info["quote_symbol"] == "SOL" + # Base priced in quote, the scale every LP bound is expressed in — not USD. + assert info["current_price"] == pytest.approx(0.000002 / 100.0) + # The caller's network, not normalize_pool_data's "solana" default. + assert info["network"] == "solana-mainnet-beta" + + +def test_the_price_is_none_when_gecko_reports_no_usd_prices(monkeypatch): + """A missing price must not become 0 — the panel would auto-fill a zero range.""" + + class _Client: + async def get_top_pools_by_network_token(self, *_a): + return _FakeRows( + [_token_row(base_token_price_usd=None, quote_token_price_usd=None)] + ) + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + info = run(pool_data.fetch_token_top_pool_info("mint", "solana", "SOL")) + assert info["current_price"] is None + + +# ── The symbol-search branch ── + + +def test_the_search_branch_lifts_the_venue_out_of_relationships(monkeypatch): + """``search/pools`` reports the dex in relationships, not as a column.""" + + class _Client: + async def api_request(self, *_a, **_kw): + return {"data": [_search_row()]} + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + info, inverted = run(pool_data.fetch_pair_top_pool_info("SOL", "USDC", "solana")) + assert inverted is False + assert info["dex_id"] == "orca" + assert info["address"] == "sol_usdc_pool" + assert info["current_price"] == pytest.approx(75.5) + + +def test_an_inverted_pool_reports_the_pair_as_asked(monkeypatch): + """Asked for XRP-RLUSD against a ``RLUSD / XRP`` pool: swap sides *and* invert + the price, or the panel would draw LP bounds on the reciprocal scale.""" + + class _Client: + async def api_request(self, *_a, **_kw): + return { + "data": [ + _search_row( + name="RLUSD / XRP", + address="rlusd_xrp", + dex="xrpl-amm", + base_token_price_quote_token="0.4", + ) + ] + } + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + info, inverted = run(pool_data.fetch_pair_top_pool_info("XRP", "RLUSD", "xrpl")) + assert inverted is True + assert (info["base_symbol"], info["quote_symbol"]) == ("XRP", "RLUSD") + assert info["current_price"] == pytest.approx(1 / 0.4) + + +def test_the_search_address_form_keeps_its_tuple_contract(monkeypatch): + class _Client: + async def api_request(self, *_a, **_kw): + return {"data": [_search_row()]} + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + assert run(pool_data.fetch_pair_top_pool("SOL", "USDC", "solana")) == ( + "sol_usdc_pool", + False, + ) + + +def test_the_search_forms_share_one_cache_entry(monkeypatch): + calls = [] + + class _Client: + async def api_request(self, *_a, **_kw): + calls.append(1) + return {"data": [_search_row()]} + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + run(pool_data.fetch_pair_top_pool_info("SOL", "USDC", "solana")) + run(pool_data.fetch_pair_top_pool("SOL", "USDC", "solana")) + assert len(calls) == 1 + + +def test_the_deepest_matching_search_pool_wins(monkeypatch): + """Ticker collisions are real, so volume decides — not result order.""" + + class _Client: + async def api_request(self, *_a, **_kw): + return { + "data": [ + _search_row(address="thin", volume_usd={"h24": 10.0}), + _search_row(address="deep", volume_usd={"h24": 900.0}), + _search_row(address="mid", volume_usd={"h24": 500.0}), + ] + } + + monkeypatch.setattr(pool_data, "_gecko_client", lambda: _Client()) + info, _ = run(pool_data.fetch_pair_top_pool_info("SOL", "USDC", "solana")) + assert info["address"] == "deep" + + +# ── resolve_pool_info dispatches like the candle path ── + + +def test_an_address_base_resolves_through_the_token_branch(monkeypatch): + seen = {} + + async def _token(mint, network, quote): + seen["token"] = (mint, network, quote) + return {"address": "from_token"} + + async def _pair(*_a): + raise AssertionError("the search branch must not be used for a mint base") + + monkeypatch.setattr(pool_data, "fetch_token_top_pool_info", _token) + monkeypatch.setattr(pool_data, "fetch_pair_top_pool_info", _pair) + mint = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" + info = run(pool_data.resolve_pool_info("solana-mainnet-beta", f"{mint}-SOL")) + assert info == {"address": "from_token"} + assert seen["token"] == (mint, "solana-mainnet-beta", "SOL") + + +def test_a_ticker_base_resolves_through_the_search_branch(monkeypatch): + async def _token(*_a): + raise AssertionError("a ticker is not a mint GeckoTerminal can look up") + + async def _pair(base, quote, network): + return {"address": "from_search", "asked": (base, quote, network)}, False + + monkeypatch.setattr(pool_data, "fetch_token_top_pool_info", _token) + monkeypatch.setattr(pool_data, "fetch_pair_top_pool_info", _pair) + info = run(pool_data.resolve_pool_info("solana-mainnet-beta", "SOL-USDC")) + assert info["address"] == "from_search" + assert info["asked"] == ("SOL", "USDC", "solana-mainnet-beta") + + +@pytest.mark.parametrize("pair", ["", "SOL", "-USDC", "SOL-", " "]) +def test_a_pair_missing_a_side_resolves_to_nothing(monkeypatch, pair): + """No request is spent on a pair that cannot name a pool.""" + + async def _boom(*_a): + raise AssertionError("no lookup should happen") + + monkeypatch.setattr(pool_data, "fetch_token_top_pool_info", _boom) + monkeypatch.setattr(pool_data, "fetch_pair_top_pool_info", _boom) + assert run(pool_data.resolve_pool_info("solana-mainnet-beta", pair)) is None + + +# ── lp_provider_for_dex refuses to guess ── + + +@pytest.mark.parametrize( + "dex_id,network,expected", + [ + # Every id below was observed coming back from the live GeckoTerminal + # search for SOL-USDC, BONK-SOL, JUP-USDC, WETH-USDC and WBNB-USDT. + ("meteora", "solana-mainnet-beta", "meteora/clmm"), + ("orca", "solana-mainnet-beta", "orca/clmm"), + ("raydium-clmm", "solana-mainnet-beta", "raydium/clmm"), + # The same venue, spelled three ways across three chains. + ("uniswap_v3", "ethereum-mainnet", "uniswap/clmm"), + ("uniswap-v3-base", "base-mainnet", "uniswap/clmm"), + ("uniswap_v3_arbitrum", "arbitrum-one", "uniswap/clmm"), + ("pancakeswap-v3-bsc", "binance-smart-chain", "pancakeswap/clmm"), + ("pancakeswap-v3-ethereum", "ethereum-mainnet", "pancakeswap/clmm"), + # A brand match is not a position model. `raydium` alone is the + # constant-product AMM v4, `meteora-damm-v2` is Meteora's AMM, and + # Uniswap v4's hooks are not the v3 CLMM the connector drives. + ("raydium", "solana-mainnet-beta", None), + ("meteora-damm-v2", "solana-mainnet-beta", None), + ("uniswap-v4-ethereum", "ethereum-mainnet", None), + ("uniswap-v2-base", "base-mainnet", None), + ("pancakeswap_v2", "binance-smart-chain", None), + # `uniswap-bsc` names no version, so it is not knowably v3. + ("uniswap-bsc", "binance-smart-chain", None), + # Right venue, wrong chain. + ("uniswap_v3", "solana-mainnet-beta", None), + ("meteora", "ethereum-mainnet", None), + ("pancakeswap-v3-solana", "solana-mainnet-beta", None), + # Not a venue Condor can LP on at all. + ("sushiswap-v3-ethereum", "ethereum-mainnet", None), + ("aerodrome-slipstream-3", "base-mainnet", None), + ("humidifi", "solana-mainnet-beta", None), + ("pumpswap", "solana-mainnet-beta", None), + ("unknown", "solana-mainnet-beta", None), + ("", "solana-mainnet-beta", None), + ], +) +def test_lp_provider_only_names_a_clmm_venue_on_its_own_chain( + dex_id, network, expected +): + assert pool_data.lp_provider_for_dex(dex_id, network) == expected + + +# ── The route ── + + +def _call_route(monkeypatch, resolver, access=True): + from condor.web.models import WebUser + from condor.web.routes.market import get_dex_pool + + class _Cm: + def has_server_access(self, user_id, name): + return access + + monkeypatch.setattr( + "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True + ) + monkeypatch.setattr(pool_data, "resolve_pool_info", resolver) + return asyncio.run( + get_dex_pool( + "srv", + connector="solana-mainnet-beta", + trading_pair="SOL-USDC", + user=WebUser(id=1, role="user"), + ) + ) + + +_REAL_POOL = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" + + +def test_route_reports_the_pool_and_its_lp_provider(monkeypatch): + async def _resolve(_net, _pair): + return { + "address": _REAL_POOL, + "dex_id": "orca", + "current_price": 75.5, + "base_symbol": "SOL", + "quote_symbol": "USDC", + } + + assert _call_route(monkeypatch, _resolve) == { + "pool_address": _REAL_POOL, + "dex_id": "orca", + "lp_provider": "orca/clmm", + "lp_supported": True, + "current_price": 75.5, + "base_symbol": "SOL", + "quote_symbol": "USDC", + } + + +def test_route_reports_an_unsupported_venue_without_failing(monkeypatch): + """The pool is real and chartable; it just cannot take an LP position. The + panel's answer is to ask for a pool address by hand, so this is a 200.""" + + async def _resolve(_net, _pair): + return { + "address": _REAL_POOL, + "dex_id": "uniswap-v4-ethereum", + "current_price": 1871.0, + "base_symbol": "WETH", + "quote_symbol": "USDC", + } + + result = _call_route(monkeypatch, _resolve) + assert result["lp_supported"] is False + assert result["lp_provider"] is None + # The address is still reported — the chart uses it even when LP cannot. + assert result["pool_address"] == _REAL_POOL + assert result["dex_id"] == "uniswap-v4-ethereum" + + +def test_route_returns_a_200_when_no_pool_is_found(monkeypatch): + async def _resolve(_net, _pair): + return None + + assert _call_route(monkeypatch, _resolve) == { + "pool_address": None, + "dex_id": None, + "lp_provider": None, + "lp_supported": False, + "current_price": None, + "base_symbol": None, + "quote_symbol": None, + } + + +def test_route_returns_a_200_when_resolution_raises(monkeypatch): + async def _resolve(_net, _pair): + raise RuntimeError("geckoterminal down") + + assert _call_route(monkeypatch, _resolve)["lp_supported"] is False + + +def test_route_refuses_an_address_that_is_not_one(monkeypatch): + """Whatever comes back is handed to /market/candles and to the executor config, + so a malformed address is dropped rather than passed along.""" + + async def _resolve(_net, _pair): + return {"address": "not an address", "dex_id": "orca"} + + result = _call_route(monkeypatch, _resolve) + assert result["pool_address"] is None + assert result["lp_supported"] is False + + +def test_route_rejects_a_caller_without_access(monkeypatch): + from fastapi import HTTPException + + async def _resolve(_net, _pair): + raise AssertionError("access is checked before anything is resolved") + + with pytest.raises(HTTPException) as e: + _call_route(monkeypatch, _resolve, access=False) + assert e.value.status_code == 403 From 1822a1ef192af8826d3c8339469eead2e06f3c30 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 09:46:05 +0300 Subject: [PATCH 004/116] Create and draw an LP position from the trade panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LP executor is already a grid, structurally: `lower_price`/`upper_price` are the range it earns fees in, and the schema calls its `upper_limit_price` / `lower_limit_price` "grid-executor style" auto-close triggers. That is a one-to-one match with `GridBox`, and `TradeChart` draws boxes without ever reading `type` — so the whole visualization is one `computeLpOverlay` and one `case "lp":`, with no new drawing code. `custom_info` wins over `config` for the bounds because a CLMM position is snapped to the venue's bins, so the box shows where the liquidity actually sits rather than what was asked for. `ExecutorType` gains `lp` and `SupportedExecutorType` is gone. FEAT-041 introduced that second union precisely to avoid breaking the switches this commit is here to extend; keeping both would leave two answers to "which executor types exist" and no compiler pressure to keep them agreeing. Widening the real union is what made `tsc` name all four exhaustive sites (`TYPE_LABELS`, `activeValidation`, `chartProps`, the payload block) instead of failing silently in the two it cannot see. The panel resolves its pool from the server and says which one it got, because the chart already resolves one implicitly for every DEX candle it draws — making that choice visible costs one endpoint and turns a silent assumption into a stated one. A manual pool address and provider override it and survive every re-resolve, which is the real answer for anyone who cares about fee tier or bin step. When the deepest pool is not a CLMM venue nothing is auto-filled at all: the address of a router pool would only build a payload the API rejects. `connector_name` is the network and the DEX goes in `lp_provider`; the config's `keep_position` is not the flag `stop` takes, so the form says which one it sets. Every key was cross-checked against the live `lp_executor` schema — all seven required fields present, no unknown keys — and the range arithmetic reproduces the schema's own worked single-sided example exactly. FEAT-042 --- .../src/components/executor/ExecutorTable.tsx | 81 +++ .../src/components/executor/LPConfigPanel.tsx | 682 ++++++++++++++++++ frontend/src/components/executor/fields.tsx | 9 + frontend/src/components/executor/types.ts | 17 +- frontend/src/components/trade/TradeChart.tsx | 21 +- frontend/src/lib/api.ts | 24 + frontend/src/lib/connector-capabilities.ts | 15 +- frontend/src/lib/executor-overlays.ts | 78 ++ frontend/src/pages/CreateExecutor.tsx | 29 +- 9 files changed, 929 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/executor/LPConfigPanel.tsx diff --git a/frontend/src/components/executor/ExecutorTable.tsx b/frontend/src/components/executor/ExecutorTable.tsx index f6b11fb8..abb7fa4d 100644 --- a/frontend/src/components/executor/ExecutorTable.tsx +++ b/frontend/src/components/executor/ExecutorTable.tsx @@ -405,6 +405,12 @@ export function DetailPanel({ const config = executor.config || {}; const isPosition = executor.type === "position"; const isGrid = executor.type === "grid"; + const isLp = executor.type === "lp"; + // A CLMM position is snapped to the venue's bins, so the on-chain bounds differ + // from the requested ones. Show where the liquidity actually sits. + const custom = executor.custom_info || {}; + const lpLower = custom.lower_price ?? custom.price_lower ?? config.lower_price; + const lpUpper = custom.upper_price ?? custom.price_upper ?? config.upper_price; // Parse triple_barrier_config (may be a JSON string or object) const tripleBarrier: Record = (() => { @@ -673,6 +679,81 @@ export function DetailPanel({
)} + {/* LP-specific details */} + {isLp && ( +
+

+ LP Details +

+
+ {lpLower != null && ( +
+
Lower Price
+
{formatPrice(Number(lpLower))}
+
+ )} + {lpUpper != null && ( +
+
Upper Price
+
{formatPrice(Number(lpUpper))}
+
+ )} + {config.lower_limit_price != null && ( +
+
Lower Limit
+
+ {formatPrice(Number(config.lower_limit_price))} +
+
+ )} + {config.upper_limit_price != null && ( +
+
Upper Limit
+
+ {formatPrice(Number(config.upper_limit_price))} +
+
+ )} + {config.lp_provider != null && ( +
+
Provider
+
{String(config.lp_provider)}
+
+ )} + {custom.in_range != null && ( +
+
Range Status
+
{String(custom.in_range)}
+
+ )} + {config.base_amount != null && Number(config.base_amount) > 0 && ( +
+
Base Amount
+
{String(config.base_amount)}
+
+ )} + {config.quote_amount != null && Number(config.quote_amount) > 0 && ( +
+
Quote Amount
+
{String(config.quote_amount)}
+
+ )} + {config.pool_address != null && ( +
+
Pool
+
{String(config.pool_address)}
+
+ )} + {config.keep_position != null && ( +
+
Keep Position
+
{String(config.keep_position) === "true" ? "Yes" : "No"}
+
+ )} +
+
+ )} + {/* Timestamps */} {executor.timestamp > 0 && (
diff --git a/frontend/src/components/executor/LPConfigPanel.tsx b/frontend/src/components/executor/LPConfigPanel.tsx new file mode 100644 index 00000000..5d72d9bb --- /dev/null +++ b/frontend/src/components/executor/LPConfigPanel.tsx @@ -0,0 +1,682 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useReducer } from "react"; +import { AlertTriangle, Sparkles } from "lucide-react"; + +import { + AdvancedSection, + NumberField, + PriceField, + SectionHeader, + SelectField, + ToggleField, + ValidationMessages, + type FieldDispatch, +} from "./fields"; +import type { ChartPriceMapping, ExecutorValidation } from "./types"; +import { api, type DexPoolInfo } from "@/lib/api"; +import { getThemeColors } from "@/lib/theme-colors"; + +// ── Sides ── +// `side` is a TradeType enum, not a direction: it says which token(s) you are +// putting in, which in turn dictates where the range sits relative to the price. + +export const LP_SIDE_BUY = 1; // quote-only, range below the price +export const LP_SIDE_SELL = 2; // base-only, range above the price +export const LP_SIDE_RANGE = 3; // both tokens, range centered + +export type LpSide = 1 | 2 | 3; + +/** The buffer between a range bound and its auto-close trigger. */ +const LIMIT_BUFFER = 0.1; + +// ── State ── + +export interface LPState { + pool_address: string; + lp_provider: string; + lower_price: number; + upper_price: number; + lower_limit_price: number; + upper_limit_price: number; + side: LpSide; + base_amount: number; + quote_amount: number; + keep_position: boolean; + /** Meteora only: 0 Spot / 1 Curve / 2 Bid-Ask. Held as a string for SelectField. */ + strategy_type: string; + /** Half-width of the auto-filled range, as a fraction of the current price. */ + range_pct: number; + activePickField: string | null; + showAdvanced: boolean; + /** + * Whether the user has typed a pool or provider. A manual entry is the real + * answer for anyone who cares about fee tier or bin step, so it must survive + * every re-resolve of the auto-resolved pool. + */ + poolTouched: boolean; +} + +type LPAction = + | { type: "SET_FIELD"; field: string; value: unknown } + | { type: "SET_CONNECTOR"; value: string } + | { type: "SET_PAIR"; value: string } + | { type: "RESOLVED"; pool: DexPoolInfo } + | { type: "AUTO_RANGE"; price: number } + | { type: "SET_SIDE"; value: LpSide; price: number | null } + | { type: "SET_RANGE_PCT"; value: number; price: number | null }; + +const DEFAULTS: LPState = { + pool_address: "", + lp_provider: "", + lower_price: 0, + upper_price: 0, + lower_limit_price: 0, + upper_limit_price: 0, + side: LP_SIDE_RANGE, + base_amount: 0, + quote_amount: 0, + keep_position: true, + strategy_type: "0", + range_pct: 0.05, + activePickField: null, + showAdvanced: false, + poolTouched: false, +}; + +const STORAGE_KEY = "condor_lp_defaults"; + +// The pool and the range belong to a pair, not to the user's habits, so neither is +// persisted — only the shape of position they tend to open. +const PERSISTED_FIELDS: (keyof LPState)[] = [ + "side", + "base_amount", + "quote_amount", + "keep_position", + "strategy_type", + "range_pct", +]; + +function loadSavedDefaults(): LPState { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULTS; + const saved = JSON.parse(raw); + const merged = { ...DEFAULTS }; + for (const key of PERSISTED_FIELDS) { + if (key in saved && saved[key] !== undefined) { + (merged as Record)[key] = saved[key]; + } + } + return merged; + } catch { + return DEFAULTS; + } +} + +function saveDefaults(state: LPState) { + const toSave: Record = {}; + for (const key of PERSISTED_FIELDS) toSave[key] = state[key]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); +} + +/** + * Range bounds and auto-close triggers for a side, anchored on the current price. + * + * A single-sided position has to start out of range for its conversion to happen + * in the direction the tokens allow: quote-only (BUY) converts to base as the + * price *falls* through a range below it, base-only (SELL) converts to quote as + * the price *rises* through a range above it. Centering either one would leave + * half the range unusable. + */ +export function rangeForSide(side: LpSide, price: number, pct: number) { + let lower: number; + let upper: number; + if (side === LP_SIDE_BUY) { + lower = price * (1 - pct); + upper = price; + } else if (side === LP_SIDE_SELL) { + lower = price; + upper = price * (1 + pct); + } else { + lower = price * (1 - pct); + upper = price * (1 + pct); + } + return { + lower_price: lower, + upper_price: upper, + lower_limit_price: lower * (1 - LIMIT_BUFFER), + upper_limit_price: upper * (1 + LIMIT_BUFFER), + }; +} + +function lpReducer(state: LPState, action: LPAction): LPState { + switch (action.type) { + case "SET_FIELD": { + const touched = + action.field === "pool_address" || action.field === "lp_provider"; + return { + ...state, + [action.field]: action.value, + ...(touched ? { poolTouched: true } : {}), + }; + } + case "SET_CONNECTOR": + case "SET_PAIR": + // A pool address belongs to one pair on one chain. Carrying it across would + // submit a position in a pool that trades something else entirely. + return { + ...state, + pool_address: "", + lp_provider: "", + poolTouched: false, + lower_price: 0, + upper_price: 0, + lower_limit_price: 0, + upper_limit_price: 0, + }; + case "RESOLVED": { + // Only a pool that can actually take a position is filled in. Handing over + // the address of a router pool would just build a payload the API rejects. + if (state.poolTouched || !action.pool.lp_supported) return state; + return { + ...state, + pool_address: action.pool.pool_address ?? "", + lp_provider: action.pool.lp_provider ?? "", + }; + } + case "AUTO_RANGE": + return { ...state, ...rangeForSide(state.side, action.price, state.range_pct) }; + case "SET_SIDE": + return { + ...state, + side: action.value, + ...(action.price && action.price > 0 + ? rangeForSide(action.value, action.price, state.range_pct) + : {}), + }; + case "SET_RANGE_PCT": + return { + ...state, + range_pct: action.value, + ...(action.price && action.price > 0 + ? rangeForSide(state.side, action.price, action.value) + : {}), + }; + default: + return state; + } +} + +// ── Validation ── + +export function useLpValidation(state: LPState): ExecutorValidation { + return useMemo(() => { + const errors: string[] = []; + if (!state.pool_address) errors.push("Pool address required"); + if (!state.lp_provider) errors.push("LP provider required (e.g. meteora/clmm)"); + if (state.lower_price <= 0) errors.push("Lower price required"); + if (state.upper_price <= 0) errors.push("Upper price required"); + if ( + state.lower_price > 0 && + state.upper_price > 0 && + state.upper_price <= state.lower_price + ) { + errors.push("Upper price must be above lower price"); + } + if (state.base_amount <= 0 && state.quote_amount <= 0) { + errors.push("At least one amount required"); + } + return { valid: errors.length === 0, errors }; + }, [state]); +} + +/** + * Things the schema permits but that are rarely meant. Kept apart from validation + * because none of them should block a create — a deliberately odd position is + * still a position. + */ +function rangeWarnings(state: LPState, price: number | null): string[] { + const warnings: string[] = []; + const bothAmounts = state.base_amount > 0 && state.quote_amount > 0; + + if (state.side === LP_SIDE_RANGE && !bothAmounts) { + warnings.push("RANGE is double-sided; only one amount is set"); + } + if (state.side !== LP_SIDE_RANGE && bothAmounts) { + warnings.push("Single-sided side with both amounts set — one will be unused"); + } + if (price && price > 0) { + if (state.side === LP_SIDE_BUY && state.lower_price > price) { + warnings.push("BUY (quote-only) usually ranges below the current price"); + } + if (state.side === LP_SIDE_SELL && state.upper_price < price) { + warnings.push("SELL (base-only) usually ranges above the current price"); + } + } + if (state.upper_limit_price > 0 && state.upper_limit_price <= state.upper_price) { + warnings.push("Upper limit sits inside the range"); + } + if (state.lower_limit_price > 0 && state.lower_limit_price >= state.lower_price) { + warnings.push("Lower limit sits inside the range"); + } + return warnings; +} + +// ── Chart pick slots ── +// The chart carries three pick slots. Upper/lower bounds and the upper limit get +// them; the lower limit is typed, and its PriceField offers no crosshair. +const PICK_SLOT: Record = { + upper_price: "start", + lower_price: "end", + upper_limit_price: "limit", +}; + +const SLOT_FIELD: Record<"start" | "end" | "limit", keyof LPState> = { + start: "upper_price", + end: "lower_price", + limit: "upper_limit_price", +}; + +export function isMeteoraProvider(provider: string): boolean { + return provider.toLowerCase().startsWith("meteora/"); +} + +// ── Hook ── + +export function useLpConfig( + server: string | null, + connector: string, + pair: string, + enabled: boolean, +) { + const [state, dispatch] = useReducer(lpReducer, undefined, loadSavedDefaults); + const validation = useLpValidation(state); + + const { data: pool, isFetching: poolFetching } = useQuery({ + queryKey: ["dex-pool", server, connector, pair], + queryFn: () => api.getDexPool(server!, connector, pair), + enabled: enabled && !!server && !!connector && !!pair, + staleTime: 60 * 1000, + }); + + useEffect(() => { + if (pool) dispatch({ type: "RESOLVED", pool }); + }, [pool]); + + const chartProps: ChartPriceMapping = useMemo( + () => ({ + startPrice: state.upper_price, + endPrice: state.lower_price, + limitPrice: state.upper_limit_price, + // ChartPriceMapping.side is 1 | 2 and only selects the picker's color, so + // RANGE collapses onto the BUY color rather than growing the union. + side: state.side === LP_SIDE_SELL ? 2 : 1, + minSpread: 0, + activePickField: PICK_SLOT[state.activePickField ?? ""] ?? null, + extraLines: + state.lower_limit_price > 0 + ? [ + { + price: state.lower_limit_price, + label: "Lower limit", + color: getThemeColors().red, + lineStyle: "dotted" as const, + lineWidth: 1, + }, + ] + : undefined, + }), + [ + state.upper_price, + state.lower_price, + state.upper_limit_price, + state.lower_limit_price, + state.side, + state.activePickField, + ], + ); + + const buildPayload = (connectorName: string, tradingPair: string) => { + const config: Record = { + // The NETWORK (solana-mainnet-beta), not the DEX — the API rejects + // `meteora/clmm` here with "Invalid network format". The DEX is lp_provider. + connector_name: connectorName, + lp_provider: state.lp_provider, + trading_pair: tradingPair, + pool_address: state.pool_address, + lower_price: state.lower_price, + upper_price: state.upper_price, + side: state.side, + base_amount: state.base_amount, + quote_amount: state.quote_amount, + keep_position: state.keep_position, + }; + // Both default to null (no trigger); 0 is not a way to say "unset". + if (state.upper_limit_price > 0) config.upper_limit_price = state.upper_limit_price; + if (state.lower_limit_price > 0) config.lower_limit_price = state.lower_limit_price; + // strategyType is Meteora's alone — any other provider rejects extra_params. + if (isMeteoraProvider(state.lp_provider)) { + config.extra_params = { strategyType: Number(state.strategy_type) }; + } + return { executor_type: "lp_executor" as const, config }; + }; + + const save = () => saveDefaults(state); + + const handleChartPriceSet = (field: "start" | "end" | "limit", price: number) => { + dispatch({ type: "SET_FIELD", field: SLOT_FIELD[field], value: price }); + dispatch({ type: "SET_FIELD", field: "activePickField", value: null }); + }; + + return { + state, + dispatch, + validation, + chartProps, + buildPayload, + save, + handleChartPriceSet, + pool, + poolFetching, + }; +} + +// ── Options ── + +const SIDE_OPTIONS: { value: LpSide; label: string; hint: string }[] = [ + { value: LP_SIDE_RANGE, label: "Range", hint: "Both tokens, range around price" }, + { value: LP_SIDE_BUY, label: "Buy", hint: "Quote only, range below price" }, + { value: LP_SIDE_SELL, label: "Sell", hint: "Base only, range above price" }, +]; + +const PROVIDER_OPTIONS = [ + { value: "", label: "Select provider…" }, + { value: "meteora/clmm", label: "Meteora (DLMM)" }, + { value: "raydium/clmm", label: "Raydium (CLMM)" }, + { value: "orca/clmm", label: "Orca (Whirlpools)" }, + { value: "uniswap/clmm", label: "Uniswap V3" }, + { value: "pancakeswap/clmm", label: "PancakeSwap V3" }, +]; + +const STRATEGY_TYPE_OPTIONS = [ + { value: "0", label: "Spot (uniform)" }, + { value: "1", label: "Curve (concentrated)" }, + { value: "2", label: "Bid-Ask (edges)" }, +]; + +const RANGE_PCT_PRESETS = [0.01, 0.02, 0.05, 0.1, 0.2]; + +function truncateAddress(address: string): string { + return address.length > 16 + ? `${address.slice(0, 6)}…${address.slice(-6)}` + : address; +} + +function formatPoolPrice(price: number): string { + if (price >= 1000) return price.toFixed(2); + if (price >= 1) return price.toFixed(4); + return price.toPrecision(6); +} + +// ── Panel Component ── + +interface Props { + state: LPState; + dispatch: React.Dispatch; + validation: ExecutorValidation; + currentPrice: number | null; + pair?: string; + pool?: DexPoolInfo; + poolFetching?: boolean; +} + +export function LPConfigPanel({ + state, + dispatch, + validation, + currentPrice, + pair, + pool, + poolFetching, +}: Props) { + const d = dispatch as FieldDispatch; + + // A DEX pair's chart can be blank (no candles for the pool) while the pool's own + // price is known, so the resolved pool is a second source for the anchor. + const price = currentPrice && currentPrice > 0 ? currentPrice : pool?.current_price ?? null; + + const baseAsset = pair?.split("-")[0] || pool?.base_symbol || "base"; + const quoteAsset = pair?.split("-")[1] || pool?.quote_symbol || "quote"; + + // Anchor the range the first time a price is known. Not a reset: once the bounds + // are non-zero they are the user's, and only an explicit action moves them. + const unanchored = state.lower_price === 0 && state.upper_price === 0; + useEffect(() => { + if (price && price > 0 && unanchored) { + dispatch({ type: "AUTO_RANGE", price }); + } + }, [price, unanchored]); // eslint-disable-line react-hooks/exhaustive-deps + + const warnings = rangeWarnings(state, price); + const unsupported = !!pool && !pool.lp_supported; + + return ( +
+ {/* Resolved pool */} +
+ Pool + {poolFetching && !pool ? ( +

Resolving pool…

+ ) : unsupported ? ( +
+

+ + + The deepest pool for this pair is on{" "} + {pool?.dex_id ?? "an unknown venue"}, which + is not a CLMM venue. Enter a pool address and provider by hand. + +

+
+ ) : pool?.pool_address ? ( +
+ + {pool.dex_id} + + + {truncateAddress(pool.pool_address)} + + {pool.current_price != null && ( + + {formatPoolPrice(pool.current_price)} {quoteAsset} + + )} + {state.poolTouched && ( + + overridden + + )} +
+ ) : ( +

+ No pool found for this pair. Enter one by hand. +

+ )} + +
+ + + d({ type: "SET_FIELD", field: "pool_address", value: e.target.value.trim() }) + } + placeholder="Pool contract address" + spellCheck={false} + className="w-full rounded border border-[var(--color-border)] bg-[var(--color-bg)] px-2.5 py-1.5 font-mono text-[11px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)]/40 focus:border-[var(--color-primary)] focus:outline-none" + /> +
+ +
+ + {/* Side */} +
+ Position Side +
+ {SIDE_OPTIONS.map((opt) => ( + + ))} +
+

+ {SIDE_OPTIONS.find((o) => o.value === state.side)?.hint} +

+
+ + {/* Range */} +
+
+ Range + {price != null && price > 0 && ( + + )} +
+
+ {RANGE_PCT_PRESETS.map((pct) => ( + + ))} +
+ state.lower_price} + /> + 0 && state.lower_price < state.upper_price} + /> +
+ + {/* Amounts */} +
+ Amounts + + +
+ + {/* Auto-close triggers */} +
+ Auto-Close Triggers + state.upper_price} + hint="Close when price rises to this level" + /> + 0 && state.lower_limit_price < state.lower_price + } + hint="Close when price falls to this level" + pickable={false} + /> +

+ Both only fire while the position is out of range. Leave at 0 for no trigger. +

+
+ + + d({ type: "SET_FIELD", field: "showAdvanced", value: !state.showAdvanced }) + } + > + +

+ On close, keep the net token change as a spot position instead of swapping + back to {quoteAsset}. This sets the config field; stopping from the + executors table asks again. +

+ {isMeteoraProvider(state.lp_provider) && ( + + )} +
+ + +
+ ); +} diff --git a/frontend/src/components/executor/fields.tsx b/frontend/src/components/executor/fields.tsx index 13ebb198..90c3955f 100644 --- a/frontend/src/components/executor/fields.tsx +++ b/frontend/src/components/executor/fields.tsx @@ -21,6 +21,7 @@ export function PriceField({ dispatch, valid, hint, + pickable = true, }: { label: string; value: number; @@ -29,6 +30,12 @@ export function PriceField({ dispatch: FieldDispatch; valid: boolean; hint?: string; + /** + * Whether this price can be picked off the chart. The chart carries exactly + * three pick slots (start/end/limit), so a panel with a fourth price offers no + * crosshair for it rather than a button that does nothing. + */ + pickable?: boolean; }) { const isActive = activePickField === field; const id = useId(); @@ -71,6 +78,7 @@ export function PriceField({ : "border-[var(--color-border)] focus:border-[var(--color-primary)]" }`} /> + {pickable && ( + )}
{hint &&

{hint}

} diff --git a/frontend/src/components/executor/types.ts b/frontend/src/components/executor/types.ts index 837a091d..1b44c049 100644 --- a/frontend/src/components/executor/types.ts +++ b/frontend/src/components/executor/types.ts @@ -1,11 +1,12 @@ -export type ExecutorType = "grid" | "position" | "order" | "dca"; - -export const EXECUTOR_TYPES: { value: ExecutorType; label: string; icon: string }[] = [ - { value: "grid", label: "Grid", icon: "Grid3X3" }, - { value: "position", label: "Position", icon: "TrendingUp" }, - { value: "order", label: "Order", icon: "ArrowUpDown" }, - { value: "dca", label: "DCA", icon: "Layers" }, -]; +/** + * An executor type the trade panel implements a tab for. + * + * This is the single union: `connectorCapabilities` reports which of these a venue + * supports, `TYPE_TABS` / `TYPE_LABELS` render them, and four switches in + * `CreateExecutor` are exhaustive over it — so adding a member here is what makes + * the compiler point at every site that has to learn about it. + */ +export type ExecutorType = "grid" | "position" | "order" | "dca" | "lp"; export interface ExtraLine { price: number; diff --git a/frontend/src/components/trade/TradeChart.tsx b/frontend/src/components/trade/TradeChart.tsx index a73f8659..07d22ada 100644 --- a/frontend/src/components/trade/TradeChart.tsx +++ b/frontend/src/components/trade/TradeChart.tsx @@ -349,11 +349,22 @@ export function TradeChart({ detailRows += `
${escapeHtml(label)}${escapeHtml(value)}
`; }; - // Grid-specific details - if (o.type === "grid" && o.gridBox) { - addRow("Start Price", fmtPrice(o.gridBox.startPrice)); - addRow("End Price", fmtPrice(o.gridBox.endPrice)); - if (o.gridBox.limitPrice) addRow("Limit Price", fmtPrice(o.gridBox.limitPrice)); + // Range-box details. Any executor drawn as a box describes itself by its + // bounds, not by an entry→exit pair; only the labels differ per type. + if (o.gridBox) { + if (o.type === "lp") { + // startPrice is the box's upper edge (see computeLpOverlay). + addRow("Upper Price", fmtPrice(o.gridBox.startPrice)); + addRow("Lower Price", fmtPrice(o.gridBox.endPrice)); + const cfgLower = Number(cfg.lower_limit_price); + if (o.gridBox.limitPrice) addRow("Upper Limit", fmtPrice(o.gridBox.limitPrice)); + if (cfgLower > 0) addRow("Lower Limit", fmtPrice(cfgLower)); + if (cfg.lp_provider != null) addRow("Provider", String(cfg.lp_provider)); + } else { + addRow("Start Price", fmtPrice(o.gridBox.startPrice)); + addRow("End Price", fmtPrice(o.gridBox.endPrice)); + if (o.gridBox.limitPrice) addRow("Limit Price", fmtPrice(o.gridBox.limitPrice)); + } } else if (o.entryPrice && o.entryPrice > 0) { addRow("Entry", fmtPrice(o.entryPrice)); if (o.exitPrice && o.exitPrice > 0 && o.exitPrice !== o.entryPrice) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0d9d9bb4..9982cd7b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -258,6 +258,25 @@ export interface MarketPrice { best_ask: number; } +/** + * The pool a DEX pair trades in — the same one the chart's candles come from. + * + * `lp_supported` is false, with a null `lp_provider`, whenever the deepest pool is + * not one an `lp_executor` can add liquidity to (a router, a plain AMM, Uniswap + * v4). That is a normal answer, not an error: the LP panel responds by asking for + * a pool address by hand. `current_price` is the base priced in quote — the scale + * every LP bound is expressed in — and is null when GeckoTerminal reports no price. + */ +export interface DexPoolInfo { + pool_address: string | null; + dex_id: string | null; + lp_provider: string | null; + lp_supported: boolean; + current_price: number | null; + base_symbol: string | null; + quote_symbol: string | null; +} + export interface OrderBookLevel { price: number; amount: number; @@ -1185,6 +1204,11 @@ export const api = { `/api/v1/servers/${encodeURIComponent(server)}/market/gateway-networks`, ).then((r) => r.networks), + getDexPool: (server: string, connector: string, pair: string) => + apiFetch( + `/api/v1/servers/${encodeURIComponent(server)}/market/dex-pool?connector=${encodeURIComponent(connector)}&trading_pair=${encodeURIComponent(pair)}`, + ), + getPrice: (server: string, connector: string, pair: string) => apiFetch( `/api/v1/servers/${encodeURIComponent(server)}/market/prices?connector=${encodeURIComponent(connector)}&trading_pair=${encodeURIComponent(pair)}`, diff --git a/frontend/src/lib/connector-capabilities.ts b/frontend/src/lib/connector-capabilities.ts index 4998f770..3d79c2fa 100644 --- a/frontend/src/lib/connector-capabilities.ts +++ b/frontend/src/lib/connector-capabilities.ts @@ -19,18 +19,10 @@ import type { ExecutorType } from "@/components/executor/types"; export type ConnectorKind = "cex" | "dex"; -/** - * An executor type a *venue* supports, which is not yet the same set as the tabs - * the panel implements: `"lp"` has no entry in `TYPE_TABS` until FEAT-042 adds - * one. Keeping it out of `ExecutorType` leaves that union meaning "a tab this - * panel can render", so the exhaustive switches over it stay exhaustive. - */ -export type SupportedExecutorType = ExecutorType | "lp"; - export interface ConnectorCapabilities { kind: ConnectorKind; /** Executor types this venue supports. */ - executorTypes: SupportedExecutorType[]; + executorTypes: ExecutorType[]; /** Allowed `execution_strategy` values (subset of OrderConfigPanel's options). */ orderStrategies: string[]; /** Whether `/market/order-book` and `/market/tickers` answer — depth + markets tabs. */ @@ -48,9 +40,8 @@ const CEX_CAPABILITIES: ConnectorCapabilities = { }; // A gateway swap has no resting order book to post to, so MARKET is the only -// execution strategy that means anything. `lp` is listed from day one; the tab -// list filters against TYPE_TABS, so it simply finds no entry until FEAT-042 -// adds one. +// execution strategy that means anything. `lp` is the CLMM liquidity position — +// the second, and only other, executor a gateway network supports. const DEX_CAPABILITIES: ConnectorCapabilities = { kind: "dex", executorTypes: ["order", "lp"], diff --git a/frontend/src/lib/executor-overlays.ts b/frontend/src/lib/executor-overlays.ts index 04c70245..ef0e087c 100644 --- a/frontend/src/lib/executor-overlays.ts +++ b/frontend/src/lib/executor-overlays.ts @@ -283,6 +283,82 @@ function computeGridOverlay(executor: ExecutorInfo): ExecutorOverlay { }; } +// ── LP Executor Overlay ── + +/** + * A CLMM liquidity position, drawn as the grid it structurally is. + * + * `lower_price` / `upper_price` are the range the position earns fees in, and the + * schema itself describes `upper_limit_price` / `lower_limit_price` as + * "grid-executor style" auto-close triggers. That is a one-to-one match with + * `GridBox`, and `TradeChart` draws boxes without ever reading `type` — so no new + * drawing code is involved, only a second producer of the same struct. + */ +function computeLpOverlay(executor: ExecutorInfo): ExecutorOverlay { + const customInfo = executor.custom_info || {}; + const config = executor.config || {}; + const side = normSide(String(customInfo.side || executor.side || config.side)); + + // custom_info wins: a CLMM position is snapped to the venue's bins, so the + // on-chain bounds are not the requested ones, and the box has to show where the + // liquidity actually sits. Same precedence handlers/dex/liquidity.py applies when + // it reads positions back. + const num = (v: unknown) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + const lower = num(customInfo.lower_price ?? customInfo.price_lower ?? config.lower_price); + const upper = num(customInfo.upper_price ?? customInfo.price_upper ?? config.upper_price); + const upperLimit = num(customInfo.upper_limit_price ?? config.upper_limit_price); + const lowerLimit = num(customInfo.lower_limit_price ?? config.lower_limit_price); + + const start = executor.timestamp > 0 ? executor.timestamp : Math.floor(Date.now() / 1000); + const end = executor.close_timestamp > 0 ? executor.close_timestamp : Math.floor(Date.now() / 1000); + + let gridBox: GridBox | undefined; + if (lower > 0 && upper > 0 && start > 0) { + gridBox = { + startTime: start, + endTime: end, + // startPrice is the box's dashed edge and endPrice its solid one; the grid + // overlay puts start_price (the far bound) first, so upper goes first here. + startPrice: upper, + endPrice: lower, + limitPrice: upperLimit > 0 ? upperLimit : undefined, + color: pnlHexColor(executor.pnl >= 0 ? 1 : -1), + }; + } + + // Both triggers, as full-width lines. TradeChart only draws these for a running + // or selected executor, which is exactly when they are actionable. + const lines: PriceLine[] = []; + if (upperLimit > 0) { + lines.push({ price: upperLimit, label: "Upper limit", color: getThemeColors().red, style: "dotted" }); + } + if (lowerLimit > 0) { + lines.push({ price: lowerLimit, label: "Lower limit", color: getThemeColors().red, style: "dotted" }); + } + + return { + executorId: executor.id, + type: "lp", + side, + status: executor.status, + closeType: executor.close_type, + pnl: executor.pnl, + pnlPct: executor.net_pnl_pct, + volume: executor.volume, + fees: executor.cum_fees_quote, + priceLines: lines, + markers: [], + gridBox, + timeRange: { start, end }, + config: executor.config, + entryPrice: lower, + exitPrice: upper, + }; +} + // ── Order Executor Overlay ── function computeOrderOverlay(executor: ExecutorInfo): ExecutorOverlay { @@ -487,6 +563,8 @@ export function computeExecutorOverlay(executor: ExecutorInfo): ExecutorOverlay return computeGridOverlay(executor); case "order": return computeOrderOverlay(executor); + case "lp": + return computeLpOverlay(executor); default: return computeGenericOverlay(executor); } diff --git a/frontend/src/pages/CreateExecutor.tsx b/frontend/src/pages/CreateExecutor.tsx index 51e925a6..3603bdfc 100644 --- a/frontend/src/pages/CreateExecutor.tsx +++ b/frontend/src/pages/CreateExecutor.tsx @@ -7,6 +7,7 @@ import { BarChart3, CheckCircle, Copy, + Droplets, Grid3X3, Layers, List, @@ -28,6 +29,7 @@ import { GridConfigPanel, useGridValidation } from "@/components/grid/GridConfig import { PositionConfigPanel, usePositionConfig } from "@/components/executor/PositionConfigPanel"; import { OrderConfigPanel, useOrderConfig } from "@/components/executor/OrderConfigPanel"; import { DCAConfigPanel, useDCAConfig } from "@/components/executor/DCAConfigPanel"; +import { LPConfigPanel, useLpConfig } from "@/components/executor/LPConfigPanel"; import { TradeBottomPane } from "@/components/trade/TradeBottomPane"; import { useCandleStore } from "@/hooks/useCandleStore"; import { useServer } from "@/hooks/useServer"; @@ -56,6 +58,7 @@ const TYPE_TABS: { value: ExecutorType; label: string; icon: React.ReactNode }[] { value: "position", label: "Position", icon: }, { value: "grid", label: "Grid", icon: }, { value: "dca", label: "DCA", icon: }, + { value: "lp", label: "LP", icon: }, ]; const TYPE_LABELS: Record = { @@ -63,6 +66,7 @@ const TYPE_LABELS: Record = { position: "Position Executor", order: "Order Executor", dca: "DCA Executor", + lp: "LP Executor", }; // ── Page ── @@ -175,6 +179,11 @@ export function CreateExecutor() { [connector, gatewayNetworks], ); + // Pool resolution only means something for a gateway network, so the query is off + // for a CEX rather than asking about a pair that has no pool. Declared above the + // connector/pair propagation effects that dispatch into it. + const lpConfig = useLpConfig(server ?? null, connector, pair, caps.kind === "dex"); + // WS for executor data (candle streams are managed by candleStore) const wsChannels = useMemo( () => server ? [`executors:${server}`] : [], @@ -241,12 +250,14 @@ export function CreateExecutor() { positionConfig.dispatch({ type: "SET_CONNECTOR", value: connector }); orderConfig.dispatch({ type: "SET_CONNECTOR", value: connector }); dcaConfig.dispatch({ type: "SET_CONNECTOR", value: connector }); + lpConfig.dispatch({ type: "SET_CONNECTOR", value: connector }); }, [connector]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { positionConfig.dispatch({ type: "SET_PAIR", value: pair }); orderConfig.dispatch({ type: "SET_PAIR", value: pair }); dcaConfig.dispatch({ type: "SET_PAIR", value: pair }); + lpConfig.dispatch({ type: "SET_PAIR", value: pair }); }, [pair]); // eslint-disable-line react-hooks/exhaustive-deps // Current price. /market/prices is a Hummingbot API call with no DEX answer, so a @@ -272,6 +283,7 @@ export function CreateExecutor() { ? (sharedCandles[sharedCandles.length - 1]?.close ?? null) : (priceData?.mid_price ?? null); + // Price precision const pricePrecision = useMemo(() => { if (!rulesData?.rules) return undefined; @@ -293,8 +305,9 @@ export function CreateExecutor() { case "position": return positionConfig.validation; case "order": return orderConfig.validation; case "dca": return dcaConfig.validation; + case "lp": return lpConfig.validation; } - }, [executorType, gridValidation, positionConfig.validation, orderConfig.validation, dcaConfig.validation]); + }, [executorType, gridValidation, positionConfig.validation, orderConfig.validation, dcaConfig.validation, lpConfig.validation]); // Chart props depend on active type const chartProps = useMemo(() => { @@ -311,8 +324,9 @@ export function CreateExecutor() { case "position": return positionConfig.chartProps; case "order": return orderConfig.chartProps; case "dca": return dcaConfig.chartProps; + case "lp": return lpConfig.chartProps; } - }, [executorType, gridState, positionConfig.chartProps, orderConfig.chartProps, dcaConfig.chartProps]); + }, [executorType, gridState, positionConfig.chartProps, orderConfig.chartProps, dcaConfig.chartProps, lpConfig.chartProps]); // Chart price set handler const handlePriceSet = useMemo( @@ -331,6 +345,9 @@ export function CreateExecutor() { case "dca": dcaConfig.handleChartPriceSet(field, price); break; + case "lp": + lpConfig.handleChartPriceSet(field, price); + break; } }, [executorType], // eslint-disable-line react-hooks/exhaustive-deps @@ -381,6 +398,10 @@ export function CreateExecutor() { case "dca": payload = dcaConfig.buildPayload(connector, pair, isSpot); break; + case "lp": + // No isSpot: an LP position has no leverage, and connector is the network. + payload = lpConfig.buildPayload(connector, pair); + break; } return api.createExecutor(server, payload); @@ -392,6 +413,7 @@ export function CreateExecutor() { case "position": positionConfig.save(); break; case "order": orderConfig.save(); break; case "dca": dcaConfig.save(); break; + case "lp": lpConfig.save(); break; } // Show success modal setSuccessInfo({ id: data.executor_id, type: executorType, connector, pair }); @@ -633,6 +655,9 @@ export function CreateExecutor() { {executorType === "dca" && ( )} + {executorType === "lp" && ( + + )} {/* Sticky Create Footer */} From 5e16abb004b5c578272a0c6eb049b26d9b93e10d Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 14:48:39 +0300 Subject: [PATCH 005/116] (arch) normalize backtest sides server-side, drop the TypeScript copy (ARCH-121) The backtest payload was the one executor wire that never ran through normalize_executor_side, so the dashboard carried its own TypeScript reimplementation of the rule (normalizeSide) plus a partial inline copy in TradeBottomPane. normalize_backtest_task now applies the canonical normalizer at every boundary where a backtest envelope enters Condor -- the poll loop and the three web reads -- so old saved results normalize on the way out too, and the frontend just reads the side. --- condor/backtesting.py | 30 ++++++++- condor/web/routes/backtesting.py | 9 +-- .../src/components/trade/TradeBottomPane.tsx | 2 +- frontend/src/lib/backtest.ts | 14 +--- tests/test_side_normalization.py | 67 +++++++++++++++++++ 5 files changed, 105 insertions(+), 17 deletions(-) diff --git a/condor/backtesting.py b/condor/backtesting.py index 5a099816..28aa7e07 100644 --- a/condor/backtesting.py +++ b/condor/backtesting.py @@ -15,6 +15,8 @@ import time from typing import Any +from condor.fetchers.executors import normalize_executor_side + logger = logging.getLogger(__name__) # Polling defaults for run_and_save. The timeout is deliberately generous: a caller @@ -57,6 +59,32 @@ def coerce_controller_config(config: dict) -> dict: return out +def normalize_backtest_task(task: Any) -> Any: + """Normalize the executor sides inside a backtest envelope, in place. + + The backtesting engine reports a side the way the raw API does -- ``1``, + ``TradeType.BUY``, ``LONG`` -- and this payload is the one wire that never went + through :func:`condor.fetchers.executors.normalize_executor_side`, so the + dashboard carried a TypeScript reimplementation of the rule to render a backtest + (ARCH-121). Applying the canonical normalizer here makes the backtest wire match + the executor wire, leaving exactly one definition of the rule. + + Accepts either the task envelope (``{status, config, result, ...}``) or a bare + result payload, and only rewrites a ``side`` that is actually present, so it adds + nothing to the raw payload the dashboard also renders as JSON. + """ + if not isinstance(task, dict): + return task + result = task.get("result") + payload = result if isinstance(result, dict) else task + executors = payload.get("executors") + if isinstance(executors, list): + for ex in executors: + if isinstance(ex, dict) and "side" in ex: + ex["side"] = normalize_executor_side(ex["side"]) + return task + + async def run_and_save( client, server: str, @@ -130,7 +158,7 @@ async def _poll_task( f"Backtest {task_id} returned an unreadable task: {task}" ) if task.get("status") in _TERMINAL: - return task + return normalize_backtest_task(task) if time.monotonic() >= deadline: raise BacktestError( f"Backtest {task_id} is still {task.get('status') or 'running'} after " diff --git a/condor/web/routes/backtesting.py b/condor/web/routes/backtesting.py index eace66f7..d43d82d4 100644 --- a/condor/web/routes/backtesting.py +++ b/condor/web/routes/backtesting.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from condor.backtest_store import get_backtest_store -from condor.backtesting import coerce_controller_config +from condor.backtesting import coerce_controller_config, normalize_backtest_task from condor.web.auth import get_current_user from condor.web.models import WebUser from config_manager import get_config_manager @@ -96,6 +96,7 @@ async def list_backtest_tasks( tid = task.get("task_id", "") if store.get_result(tid): task["saved"] = True + normalize_backtest_task(task) return live_tasks @@ -122,14 +123,14 @@ async def get_backtest_task( store.save_result(name, task_id, result) result["saved"] = True - return result + return normalize_backtest_task(result) except Exception: pass # Fallback to saved saved = store.get_result(task_id) if saved: - return {**saved, "saved": True} + return normalize_backtest_task({**saved, "saved": True}) raise HTTPException(status_code=404, detail="Task not found") @@ -168,7 +169,7 @@ async def list_saved_results( raise HTTPException(status_code=403, detail="No access") store = get_backtest_store() - return store.list_results(name) + return [normalize_backtest_task(entry) for entry in store.list_results(name)] @router.delete("/servers/{name}/backtesting/saved/{task_id}") diff --git a/frontend/src/components/trade/TradeBottomPane.tsx b/frontend/src/components/trade/TradeBottomPane.tsx index b9e4995f..ae3b307f 100644 --- a/frontend/src/components/trade/TradeBottomPane.tsx +++ b/frontend/src/components/trade/TradeBottomPane.tsx @@ -496,7 +496,7 @@ export function TradeBottomPane({ const active = isExecutorActive(ex.status); const stopping = stoppingIds.has(ex.id); const side = ex.side?.toUpperCase(); - const isBuy = side === "BUY" || side === "1"; + const isBuy = side === "BUY"; const borderColor = active ? "var(--color-primary)" : ex.pnl >= 0 ? "var(--color-green)" : "var(--color-red)"; const isSelected = selectedExecutorId === ex.id; const entry = getEntryPrice(ex); diff --git a/frontend/src/lib/backtest.ts b/frontend/src/lib/backtest.ts index 8a718ac8..68642d6f 100644 --- a/frontend/src/lib/backtest.ts +++ b/frontend/src/lib/backtest.ts @@ -168,7 +168,9 @@ export function extractResults(taskResults: Record): BacktestDa id: String(e.id ?? e.executor_id ?? ""), timestamp: (e.timestamp ?? 0) as number, closeTimestamp: (e.close_timestamp ?? 0) as number, - side: normalizeSide(e.side), + // Already canonical BUY/SELL: the backtest route runs the payload through + // the same `normalize_executor_side` the executor wire uses (ARCH-121). + side: String(e.side ?? ""), closeType: String(e.close_type ?? ""), netPnlQuote: (e.net_pnl_quote ?? 0) as number, filledAmountQuote: (e.filled_amount_quote ?? 0) as number, @@ -215,13 +217,3 @@ export function extractResults(taskResults: Record): BacktestDa raw: taskResults, }; } - -export function normalizeSide(side: unknown): string { - if (typeof side === "string") { - if (side === "1" || side.toUpperCase() === "BUY" || side === "TradeType.BUY") return "BUY"; - if (side === "2" || side.toUpperCase() === "SELL" || side === "TradeType.SELL") return "SELL"; - return side; - } - if (typeof side === "number") return side === 1 ? "BUY" : "SELL"; - return String(side ?? ""); -} diff --git a/tests/test_side_normalization.py b/tests/test_side_normalization.py index ef2d5b3a..5714c8c2 100644 --- a/tests/test_side_normalization.py +++ b/tests/test_side_normalization.py @@ -13,6 +13,7 @@ import pytest from condor.agents.performance import _executor_row +from condor.backtesting import normalize_backtest_task from condor.fetchers.executors import normalize_executor_side REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -85,3 +86,69 @@ def test_no_private_side_normalizer_survives(): ] assert offenders == [] + + +# ── ARCH-121: the backtest wire carries the same normalized side ── + + +def test_backtest_task_sides_are_normalized_in_the_envelope(): + """The dashboard reads ``task["result"]["executors"][i]["side"]`` verbatim now.""" + task = { + "task_id": "t1", + "status": "completed", + "result": { + "executors": [ + {"id": "e1", "side": "TradeType.BUY"}, + {"id": "e2", "side": 2}, + {"id": "e3", "side": "SHORT"}, + {"id": "e4", "side": "1"}, + ] + }, + } + + normalize_backtest_task(task) + + assert [e["side"] for e in task["result"]["executors"]] == [ + "BUY", + "SELL", + "SELL", + "BUY", + ] + + +def test_backtest_payload_without_an_envelope_is_normalized_too(): + """A bare result payload — what a caller holding only ``result`` passes in.""" + payload = {"executors": [{"id": "e1", "side": "sell"}]} + + assert normalize_backtest_task(payload)["executors"][0]["side"] == "SELL" + + +def test_backtest_normalization_adds_no_side_key(): + """The raw payload is rendered as JSON in the dashboard: do not invent fields.""" + task = {"result": {"executors": [{"id": "e1"}]}} + + normalize_backtest_task(task) + + assert task["result"]["executors"] == [{"id": "e1"}] + + +@pytest.mark.parametrize("task", [None, "nope", {}, {"result": {"executors": "nope"}}]) +def test_backtest_normalization_tolerates_a_payload_without_executors(task): + """A pending/failed task has no executors list; the route still returns it.""" + assert normalize_backtest_task(task) == task + + +def test_no_typescript_copy_of_the_side_rule_survives(): + """ARCH-121: ``normalizeSide`` was a second definition of the rule, in TypeScript. + + Deleted once the backtest route started emitting a normalized side. If it comes + back, the rule has two definitions in two languages again, free to drift. + """ + src = REPO_ROOT / "frontend" / "src" + offenders = [ + str(path.relative_to(REPO_ROOT)) + for path in src.rglob("*.ts*") + if "function normalizeSide" in path.read_text() + ] + + assert offenders == [] From 476764ae0dee3add185d42a5003986e94c51a651 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 17:27:08 +0300 Subject: [PATCH 006/116] Serve venue traits instead of a CEX/DEX binary `fetch_gateway_networks` handed the trade panel one fact -- "this connector is a chartable gateway network" -- and the panel used it to answer four different questions: which executor tabs exist, which execution strategies exist, whether the Hummingbot market endpoints are called, and where the current price comes from. Those questions do not have a common answer. `xrpl` is the proof. It is a first-class Hummingbot connector with a real order book whose charts nonetheless come from GeckoTerminal, so it lives in NETWORK_TO_GECKO -- one of the two sets the gateway list was intersected from. It classifies correctly today only because Gateway happens not to report it; the day a Gateway version does, it would silently lose its order book, its trading rules, its pair list and every strategy but MARKET, and gain an LP tab its AMM cannot honor. Nothing failed loudly when that happened, and nothing prevented it. `fetch_venues` replaces it with the traits themselves, one authority each: `hummingbot_market_data` from the credentialed connector list (credentials are per connector, so a venue in both lists IS a Hummingbot connector -- that is the xrpl guarantee, expressed in exactly one place), and `clmm_lp` from `network_has_clmm` over FEAT-042's _CLMM_VENUES rather than from gateway membership. The candle source stays server-side, where it already forks and where no UI decision needs it. `GET /market/venues` and ServerDataType.VENUES replace the gateway-networks endpoint and data type shipped two commits ago; the trade panel was their only consumer. The VENUES cadence follows CONNECTORS, not the "chains change ~never" one, because the credentialed list is now an input and changes when keys are added. A gateway failure degrades to the credentialed venues instead of re-raising: losing the DEX half must not empty the whole dropdown. It re-raises under strict only when that would leave nothing, so an empty list is still never cached. --- condor/fetchers/__init__.py | 4 +- condor/fetchers/connectors.py | 100 +++++-- condor/server_data_service.py | 17 +- condor/web/routes/market.py | 27 +- handlers/dex/pool_data.py | 14 + tests/test_fetcher_gateway_networks.py | 184 ------------ tests/test_fetcher_venues.py | 395 +++++++++++++++++++++++++ 7 files changed, 515 insertions(+), 226 deletions(-) delete mode 100644 tests/test_fetcher_gateway_networks.py create mode 100644 tests/test_fetcher_venues.py diff --git a/condor/fetchers/__init__.py b/condor/fetchers/__init__.py index 6241959d..defd2589 100644 --- a/condor/fetchers/__init__.py +++ b/condor/fetchers/__init__.py @@ -41,7 +41,7 @@ from condor.fetchers.connectors import ( fetch_connectors, fetch_available_cex_connectors, - fetch_gateway_networks, + fetch_venues, is_cex_connector, ) from condor.fetchers.executors import ( @@ -75,7 +75,7 @@ "fetch_trading_rules", "fetch_connectors", "fetch_available_cex_connectors", - "fetch_gateway_networks", + "fetch_venues", "is_cex_connector", "fetch_executors", "fetch_all_executors", diff --git a/condor/fetchers/connectors.py b/condor/fetchers/connectors.py index 0f4a975b..d15db9f5 100644 --- a/condor/fetchers/connectors.py +++ b/condor/fetchers/connectors.py @@ -1,7 +1,7 @@ """Fetch connector information from Hummingbot API.""" import logging -from typing import List +from typing import Dict, List from condor.fetchers._identifiers import validate_identifier @@ -35,34 +35,25 @@ def _network_id(item) -> str: return str(item) -async def fetch_gateway_networks(client, strict: bool = False, **_kw) -> List[str]: - """Gateway networks that Condor can chart (subset of ``NETWORK_TO_GECKO``). +async def _chartable_gateway_networks(client) -> List[str]: + """Gateway networks Condor can chart: ``list_networks()`` ∩ ``NETWORK_TO_GECKO``. The intersection is the point: a network is only offered to the trade panel if ``dex_candles.uses_gecko_candles`` will answer for it, so selecting one can never produce an empty chart. Do not widen this to every gateway network. - Args: - strict: Raise when the gateway request itself fails, instead of reporting - that no networks exist. Callers that cache the answer want the - distinction: an unreachable gateway is worth retrying, and must not be - cached as "this server has no DEX". + Raises whatever the gateway request raises — the caller decides how much of the + answer a gateway failure is allowed to take down. """ # Lazy, like condor.dex_candles.uses_gecko_candles — condor.fetchers must not # import handlers at module scope. from handlers.dex.pool_data import NETWORK_TO_GECKO - try: - response = await client.gateway.list_networks() - networks = (response or {}).get("networks") or [] - return sorted( - {n for n in (_network_id(i) for i in networks) if n in NETWORK_TO_GECKO} - ) - except Exception as e: - if strict: - raise - logger.error("Error fetching gateway networks: %s", e, exc_info=True) - return [] + response = await client.gateway.list_networks() + networks = (response or {}).get("networks") or [] + return sorted( + {n for n in (_network_id(i) for i in networks) if n in NETWORK_TO_GECKO} + ) async def fetch_available_cex_connectors( @@ -103,3 +94,74 @@ async def fetch_available_cex_connectors( raise logger.error("Error fetching connectors: %s", e, exc_info=True) return [] + + +async def fetch_venues( + client, account_name: str = "master_account", strict: bool = False, **_kw +) -> List[Dict]: + """Venues the trade panel can offer, each with its independent traits. + + Returns ``[{"name", "hummingbot_market_data", "clmm_lp"}, ...]`` sorted by name. + The two traits are *independent facts about the venue*, each with exactly one + authority, because the panel's four decisions (which executor tabs, which + execution strategies, whether the order-book/rules/price endpoints are called, + where the current price comes from) are four different questions. Answering them + from a single ``cex``/``dex`` membership test is what this shape replaces. + + ``hummingbot_market_data``: the venue came from the credentialed connector list. + Credentials are per Hummingbot connector, so ``solana-mainnet-beta`` can never + appear there and presence is positive evidence that ``/market/trading-rules``, + ``/market/order-book``, ``/market/tickers`` and ``/market/prices`` answer. + **A venue in both input lists is a Hummingbot connector.** That is the ``xrpl`` + guarantee and this is the only place it is expressed: ``xrpl`` is a first-class + connector with a real order book whose *charts* come from GeckoTerminal, so it + sits in ``NETWORK_TO_GECKO`` and would appear in the gateway half the day a + Gateway version reports it — without this rule it would silently lose its order + book, its trading rules and every strategy but MARKET. + + ``clmm_lp``: the venue is a gateway network *and* its gecko chain hosts a CLMM + venue (``pool_data.network_has_clmm``), not merely that it is a gateway network. + That is what keeps an order-book venue from being offered an LP tab it cannot + honor. + + The candle source (CandlesFactory vs GeckoTerminal) is deliberately *not* a + field: the server already forks on it internally and no UI decision needs it. + + Args: + strict: Raise instead of reporting a venue-less server, so a failure is + never cached as "there is nothing here". Credentials are load-bearing, + so their failure always re-raises under ``strict``. A gateway failure + only re-raises when it would leave the list empty: losing the DEX half + must not empty the whole dropdown, and a list that still carries the + credentialed venues is not a cached lie. + """ + from handlers.dex.pool_data import network_has_clmm + + # Credentials first: under strict this raises, and there is no point asking the + # gateway about a server we cannot describe at all. + connectors = await fetch_available_cex_connectors( + client, account_name=account_name, strict=strict + ) + + gateway_error = None + networks: List[str] = [] + try: + networks = await _chartable_gateway_networks(client) + except Exception as e: + gateway_error = e + logger.error("Error fetching gateway networks: %s", e, exc_info=True) + + credentialed = set(connectors) + gateway = set(networks) + venues = [ + { + "name": name, + "hummingbot_market_data": name in credentialed, + "clmm_lp": name in gateway and network_has_clmm(name), + } + for name in sorted(credentialed | gateway) + ] + + if strict and gateway_error is not None and not venues: + raise gateway_error + return venues diff --git a/condor/server_data_service.py b/condor/server_data_service.py index 2d747fa9..7223432d 100644 --- a/condor/server_data_service.py +++ b/condor/server_data_service.py @@ -45,7 +45,7 @@ class ServerDataType(Enum): CANDLE_CONNECTORS = "candle_connectors" SERVER_STATUS = "server_status" ALL_CONNECTORS = "all_connectors" - GATEWAY_NETWORKS = "gateway_networks" + VENUES = "venues" TICKERS = "tickers" TICKER_POOL = "ticker_pool" @@ -84,11 +84,10 @@ class DataTypeDefaults: ServerDataType.ALL_CONNECTORS: DataTypeDefaults( interval=300, ttl=600, stale_threshold=30 ), - # Gateway networks change ~never (a chain is added to the gateway config by - # hand), so a read is served from cache for half an hour. - ServerDataType.GATEWAY_NETWORKS: DataTypeDefaults( - interval=300, ttl=1800, stale_threshold=300 - ), + # Venue traits follow the CONNECTORS cadence, not the "chains change ~never" + # one: the credentialed connector list is one of the inputs, so the answer + # changes the moment a user adds API keys. + ServerDataType.VENUES: DataTypeDefaults(interval=300, ttl=600, stale_threshold=30), ServerDataType.TICKERS: DataTypeDefaults(interval=60, ttl=180, stale_threshold=30), # Whole-server ticker pool: one poll feeds every per-connector ticker view and # all currency conversion, so reads never hit the network. @@ -854,13 +853,13 @@ def register_default_fetches() -> None: fetch_connectors, fetch_current_price, fetch_executors, - fetch_gateway_networks, fetch_portfolio, fetch_positions, fetch_server_status, fetch_ticker_pool, fetch_tickers, fetch_trading_rules, + fetch_venues, ) sds = get_server_data_service() @@ -881,9 +880,7 @@ def register_default_fetches() -> None: ServerDataType.CONNECTORS, partial(fetch_available_cex_connectors, strict=True) ) sds.register_fetch(ServerDataType.ALL_CONNECTORS, fetch_connectors) - sds.register_fetch( - ServerDataType.GATEWAY_NETWORKS, partial(fetch_gateway_networks, strict=True) - ) + sds.register_fetch(ServerDataType.VENUES, partial(fetch_venues, strict=True)) sds.register_fetch(ServerDataType.BOTS_STATUS, fetch_bots_status) sds.register_fetch(ServerDataType.EXECUTORS, fetch_executors) sds.register_fetch(ServerDataType.BOT_RUNS, fetch_bot_runs) diff --git a/condor/web/routes/market.py b/condor/web/routes/market.py index a3c2def6..680f0eb3 100644 --- a/condor/web/routes/market.py +++ b/condor/web/routes/market.py @@ -103,13 +103,18 @@ async def get_connected_exchanges(name: str, user: WebUser = Depends(get_current return result or [] -@router.get("/servers/{name}/market/gateway-networks") -async def get_gateway_networks(name: str, user: WebUser = Depends(get_current_user)): - """Gateway networks the trade panel can offer (chartable DEX venues). - - Answers ``{"networks": []}`` rather than a 502 when the gateway container is - down: the panel then simply shows no DEX. The fetcher runs ``strict=True``, so - the failure is never cached as an empty list and the next request retries. +@router.get("/servers/{name}/market/venues") +async def get_venues(name: str, user: WebUser = Depends(get_current_user)): + """Venues the trade panel can offer, each with its independent traits. + + ``{"venues": [{"name", "hummingbot_market_data", "clmm_lp"}, ...]}``. The panel + maps traits to UI decisions itself — which tabs and strategies to render is a + product decision, not a server fact; only the facts they rest on come from here. + + Answers ``{"venues": []}`` rather than a 502 when the server cannot be described + at all, so the panel renders its empty state. The fetcher runs ``strict=True``, + so that failure is never cached; a gateway-only failure degrades inside the + fetcher and still reports the credentialed venues. """ cm = get_config_manager() if not cm.has_server_access(user.id, name): @@ -119,12 +124,12 @@ async def get_gateway_networks(name: str, user: WebUser = Depends(get_current_us try: result = await get_server_data_service().get_or_fetch( - name, ServerDataType.GATEWAY_NETWORKS + name, ServerDataType.VENUES ) except Exception as e: - logger.warning("Gateway networks unavailable for %s: %s", name, e) - return {"networks": []} - return {"networks": result or []} + logger.warning("Venues unavailable for %s: %s", name, e) + return {"venues": []} + return {"venues": result or []} _NO_POOL = { diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py index 3c31ba64..c343c434 100644 --- a/handlers/dex/pool_data.py +++ b/handlers/dex/pool_data.py @@ -489,6 +489,20 @@ async def resolve_pool_info( return info +def network_has_clmm(network: str) -> bool: + """Whether any known CLMM venue exists on this network's gecko chain. + + This is the authority for "can a venue on this network take an ``lp_executor`` + position", and it is deliberately *not* "is this a gateway network". ``xrpl`` is + a gateway-adjacent id that lives in ``NETWORK_TO_GECKO`` for charting only — its + AMM is not concentrated-liquidity, so it answers ``False`` here whether or not + Gateway ever reports it in ``list_networks()``. Deriving the trait from + ``_CLMM_VENUES`` instead of from gateway membership is what keeps that true. + """ + gecko = get_gecko_network(network) + return any(gecko in networks for _brand, _qualifiers, networks in _CLMM_VENUES) + + def lp_provider_for_dex(dex_id: str, network: str) -> Optional[str]: """``"meteora/clmm"``-style LP provider for a GeckoTerminal dex id, or ``None``. diff --git a/tests/test_fetcher_gateway_networks.py b/tests/test_fetcher_gateway_networks.py deleted file mode 100644 index a3dd7a6f..00000000 --- a/tests/test_fetcher_gateway_networks.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Tests for FEAT-041: ``fetch_gateway_networks`` normalizes and gates the list. - -The trade panel learns "this connector is a DEX" from this fetcher's output, so -two properties matter. First, gateway has returned ``list_networks`` entries as -plain strings and as ``network_id`` / ``id`` dicts across versions, and all three -have to reduce to the same id. Second, the result is intersected with -``NETWORK_TO_GECKO``: a network Condor cannot chart must never be offered, or the -user picks it and gets an empty chart (``dex_candles.uses_gecko_candles`` is an -exact-match membership test on that same dict). -""" - -import asyncio - -import pytest - -from condor.fetchers.connectors import fetch_gateway_networks - - -class FakeClient: - """Client whose ``gateway.list_networks`` replays one scripted response.""" - - def __init__(self, response=None, error=None): - self.calls = 0 - self.gateway = self._Gateway(self) - self._response = response - self._error = error - - class _Gateway: - def __init__(self, outer): - self._outer = outer - - async def list_networks(self): - self._outer.calls += 1 - if self._outer._error is not None: - raise self._outer._error - return self._outer._response - - -def _fetch(response, **kwargs): - return asyncio.run(fetch_gateway_networks(FakeClient(response), **kwargs)) - - -def test_accepts_plain_string_entries(): - result = _fetch({"networks": ["solana-mainnet-beta", "base-mainnet"]}) - assert result == ["base-mainnet", "solana-mainnet-beta"] - - -def test_accepts_network_id_dicts(): - result = _fetch({"networks": [{"network_id": "solana-mainnet-beta"}]}) - assert result == ["solana-mainnet-beta"] - - -def test_accepts_id_dicts(): - result = _fetch({"networks": [{"id": "ethereum-mainnet"}]}) - assert result == ["ethereum-mainnet"] - - -def test_mixed_shapes_normalize_to_the_same_ids(): - result = _fetch( - { - "networks": [ - "solana-mainnet-beta", - {"network_id": "base-mainnet"}, - {"id": "polygon-mainnet"}, - ] - } - ) - assert result == ["base-mainnet", "polygon-mainnet", "solana-mainnet-beta"] - - -def test_drops_networks_condor_cannot_chart(): - """A network absent from NETWORK_TO_GECKO would 502 on the candle path.""" - result = _fetch( - { - "networks": [ - "solana-mainnet-beta", - "solana-devnet", - "base-sepolia", - {"network_id": "not-a-chain"}, - ] - } - ) - assert result == ["solana-mainnet-beta"] - - -def test_deduplicates_and_sorts(): - result = _fetch( - {"networks": ["base-mainnet", {"network_id": "base-mainnet"}, "solana"]} - ) - assert result == ["base-mainnet", "solana"] - - -def test_tolerates_missing_and_empty_payloads(): - assert _fetch({}) == [] - assert _fetch({"networks": None}) == [] - assert _fetch(None) == [] - - -def test_strict_reraises_so_the_failure_is_not_cached(): - """A cached empty list would tell the panel "this server has no DEX".""" - client = FakeClient(error=RuntimeError("gateway down")) - with pytest.raises(RuntimeError): - asyncio.run(fetch_gateway_networks(client, strict=True)) - - -def test_non_strict_swallows_the_failure(): - client = FakeClient(error=RuntimeError("gateway down")) - assert asyncio.run(fetch_gateway_networks(client)) == [] - - -# ── The REST route ──────────────────────────────────────────────────────────── -# -# The panel merges this endpoint's answer into its connector dropdown, so a -# server whose gateway container is down must degrade to "no DEX offered" rather -# than break the whole selector with a 502. - - -class _FakeSds: - """Stands in for ServerDataService.get_or_fetch.""" - - def __init__(self, value=None, error=None): - self.value = value - self.error = error - self.calls = 0 - - async def get_or_fetch(self, name, data_type, **params): - self.calls += 1 - if self.error is not None: - raise self.error - return self.value - - -def _call_route(monkeypatch, sds): - from condor import server_data_service - from condor.web.models import WebUser - from condor.web.routes.market import get_gateway_networks - - monkeypatch.setattr( - server_data_service, "get_server_data_service", lambda: sds, raising=True - ) - - class _Cm: - def has_server_access(self, user_id, name): - return True - - monkeypatch.setattr( - "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True - ) - return asyncio.run(get_gateway_networks("srv", user=WebUser(id=1, role="user"))) - - -def test_route_returns_the_networks(monkeypatch): - sds = _FakeSds(value=["base-mainnet", "solana-mainnet-beta"]) - assert _call_route(monkeypatch, sds) == { - "networks": ["base-mainnet", "solana-mainnet-beta"] - } - - -def test_route_returns_empty_list_when_the_gateway_is_down(monkeypatch): - """Not a 500: the panel just shows no DEX.""" - sds = _FakeSds(error=RuntimeError("gateway down")) - assert _call_route(monkeypatch, sds) == {"networks": []} - - -def test_route_normalizes_a_none_answer(monkeypatch): - assert _call_route(monkeypatch, _FakeSds(value=None)) == {"networks": []} - - -def test_route_rejects_a_caller_without_access(monkeypatch): - from fastapi import HTTPException - - from condor.web.models import WebUser - from condor.web.routes.market import get_gateway_networks - - class _Cm: - def has_server_access(self, user_id, name): - return False - - monkeypatch.setattr( - "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True - ) - with pytest.raises(HTTPException) as e: - asyncio.run(get_gateway_networks("srv", user=WebUser(id=1, role="user"))) - assert e.value.status_code == 403 diff --git a/tests/test_fetcher_venues.py b/tests/test_fetcher_venues.py new file mode 100644 index 00000000..517e63e2 --- /dev/null +++ b/tests/test_fetcher_venues.py @@ -0,0 +1,395 @@ +"""Tests for FEAT-043: ``fetch_venues`` reports independent venue traits. + +The trade panel used to learn one fact — "this connector is a gateway network" — +and use it to answer four different questions. This fetcher replaces that with two +independent traits, each with a single authority, and the property that matters +most is the one the binary could not express: + +``xrpl`` is a first-class Hummingbot connector with a real order book whose +*charts* come from GeckoTerminal, so it lives in ``NETWORK_TO_GECKO`` — one of the +two sets the gateway half is built from. If a Gateway version ever reports ``xrpl`` +in ``list_networks()``, it must still keep ``hummingbot_market_data: true`` (order +book, trading rules, LIMIT strategies) and must still be denied ``clmm_lp`` (its +AMM is not concentrated-liquidity). No reachable Gateway reports it today, which is +exactly why it is pinned here rather than clicked. + +The gateway half also still has to normalize ``list_networks`` entries (plain +strings and ``network_id`` / ``id`` dicts across versions) and drop any network +Condor cannot chart, or the user picks one and gets an empty chart. +""" + +import asyncio + +import pytest + +from condor.fetchers.connectors import fetch_venues +from handlers.dex.pool_data import network_has_clmm + + +class FakeClient: + """Client replaying one scripted ``list_networks`` + credential answer.""" + + def __init__( + self, + networks=None, + networks_error=None, + credentials=None, + credentials_error=None, + available=None, + ): + self.gateway_calls = 0 + self.gateway = self._Gateway(self) + self.accounts = self._Accounts(self) + self.connectors = self._Connectors(self) + self._networks = networks + self._networks_error = networks_error + self._credentials = credentials if credentials is not None else [] + self._credentials_error = credentials_error + # None means "every credentialed connector is available", which is what the + # intersection in fetch_available_cex_connectors degrades to. + self._available = available + + class _Gateway: + def __init__(self, outer): + self._outer = outer + + async def list_networks(self): + self._outer.gateway_calls += 1 + if self._outer._networks_error is not None: + raise self._outer._networks_error + return self._outer._networks + + class _Accounts: + def __init__(self, outer): + self._outer = outer + + async def list_account_credentials(self, account_name): + if self._outer._credentials_error is not None: + raise self._outer._credentials_error + return list(self._outer._credentials) + + class _Connectors: + def __init__(self, outer): + self._outer = outer + + async def list_connectors(self): + if self._outer._available is None: + raise RuntimeError("connector list unavailable") + return list(self._outer._available) + + +def _fetch(**kwargs): + strict = kwargs.pop("strict", False) + return asyncio.run(fetch_venues(FakeClient(**kwargs), strict=strict)) + + +def _by_name(venues): + return {v["name"]: v for v in venues} + + +# ── network_has_clmm: the clmm_lp authority ─────────────────────────────────── + + +def test_network_has_clmm_is_true_for_chains_hosting_a_clmm_venue(): + assert network_has_clmm("solana") is True + assert network_has_clmm("solana-mainnet-beta") is True + assert network_has_clmm("ethereum") is True + assert network_has_clmm("ethereum-mainnet") is True + assert network_has_clmm("base") is True + assert network_has_clmm("base-mainnet") is True + assert network_has_clmm("binance-smart-chain") is True + + +def test_network_has_clmm_is_false_for_xrpl(): + """The whole point: xrpl charts like a DEX but has no CLMM position model.""" + assert network_has_clmm("xrpl") is False + + +def test_network_has_clmm_is_false_for_a_chain_with_no_known_clmm_venue(): + assert network_has_clmm("avalanche") is False + + +def test_network_has_clmm_is_false_for_an_unknown_network(): + assert network_has_clmm("not-a-chain") is False + assert network_has_clmm("") is False + + +# ── The xrpl regression this feature exists for ─────────────────────────────── + + +def test_xrpl_in_both_lists_keeps_its_hummingbot_market_data(): + """A venue in both input lists is a Hummingbot connector, not a DEX. + + If Gateway starts reporting ``xrpl``, the panel must not strip its order book, + its trading rules and its LIMIT strategies, and must not offer it an LP tab. + """ + venues = _by_name( + _fetch( + credentials=["binance", "xrpl"], + available=["binance", "xrpl"], + networks={"networks": ["xrpl", "solana-mainnet-beta"]}, + ) + ) + assert venues["xrpl"] == { + "name": "xrpl", + "hummingbot_market_data": True, + "clmm_lp": False, + } + # And the real gateway network in the same answer is unaffected. + assert venues["solana-mainnet-beta"] == { + "name": "solana-mainnet-beta", + "hummingbot_market_data": False, + "clmm_lp": True, + } + + +def test_xrpl_credentialed_and_absent_from_gateway_is_a_plain_connector(): + """Today's world: the traits must be identical to the both-lists case.""" + venues = _by_name( + _fetch( + credentials=["xrpl"], + available=["xrpl"], + networks={"networks": ["solana-mainnet-beta"]}, + ) + ) + assert venues["xrpl"]["hummingbot_market_data"] is True + assert venues["xrpl"]["clmm_lp"] is False + + +# ── Trait composition ───────────────────────────────────────────────────────── + + +def test_a_credentialed_connector_has_market_data_and_no_lp(): + venues = _by_name( + _fetch( + credentials=["binance", "binance_perpetual"], + available=["binance", "binance_perpetual"], + networks={"networks": []}, + ) + ) + assert venues["binance"] == { + "name": "binance", + "hummingbot_market_data": True, + "clmm_lp": False, + } + assert venues["binance_perpetual"]["hummingbot_market_data"] is True + + +def test_a_gateway_network_has_lp_and_no_market_data(): + venues = _by_name(_fetch(networks={"networks": ["solana-mainnet-beta"]})) + assert venues["solana-mainnet-beta"] == { + "name": "solana-mainnet-beta", + "hummingbot_market_data": False, + "clmm_lp": True, + } + + +def test_a_gateway_network_with_no_clmm_venue_gets_no_lp(): + """``avalanche`` is chartable but hosts none of the CLMM venues.""" + venues = _by_name(_fetch(networks={"networks": ["avalanche"]})) + assert venues["avalanche"] == { + "name": "avalanche", + "hummingbot_market_data": False, + "clmm_lp": False, + } + + +def test_venues_are_sorted_and_deduplicated(): + venues = _fetch( + credentials=["kucoin", "binance"], + available=["kucoin", "binance"], + networks={"networks": ["solana-mainnet-beta", {"id": "base-mainnet"}]}, + ) + assert [v["name"] for v in venues] == [ + "base-mainnet", + "binance", + "kucoin", + "solana-mainnet-beta", + ] + + +# ── The gateway half still normalizes and gates ─────────────────────────────── + + +def test_accepts_plain_strings_and_network_id_and_id_dicts(): + names = [ + v["name"] + for v in _fetch( + networks={ + "networks": [ + "solana-mainnet-beta", + {"network_id": "base-mainnet"}, + {"id": "polygon-mainnet"}, + ] + } + ) + ] + assert names == ["base-mainnet", "polygon-mainnet", "solana-mainnet-beta"] + + +def test_drops_networks_condor_cannot_chart(): + """A network absent from NETWORK_TO_GECKO would 502 on the candle path.""" + names = [ + v["name"] + for v in _fetch( + networks={ + "networks": [ + "solana-mainnet-beta", + "solana-devnet", + "base-sepolia", + {"network_id": "not-a-chain"}, + ] + } + ) + ] + assert names == ["solana-mainnet-beta"] + + +def test_tolerates_missing_and_empty_gateway_payloads(): + assert _fetch(networks={}) == [] + assert _fetch(networks={"networks": None}) == [] + assert _fetch(networks=None) == [] + + +# ── strict: what a failure is allowed to take down ──────────────────────────── + + +def test_a_gateway_failure_still_yields_the_credentialed_venues(): + """Losing the DEX half must not empty the whole dropdown.""" + venues = _by_name( + _fetch( + credentials=["binance"], + available=["binance"], + networks_error=RuntimeError("gateway down"), + strict=True, + ) + ) + assert venues["binance"]["hummingbot_market_data"] is True + assert len(venues) == 1 + + +def test_strict_reraises_a_gateway_failure_that_would_leave_nothing(): + """An empty list must never be cached as "this server has no venues".""" + client = FakeClient( + credentials=[], available=[], networks_error=RuntimeError("gateway down") + ) + with pytest.raises(RuntimeError): + asyncio.run(fetch_venues(client, strict=True)) + + +def test_non_strict_swallows_a_total_failure(): + client = FakeClient( + credentials=[], available=[], networks_error=RuntimeError("gateway down") + ) + assert asyncio.run(fetch_venues(client)) == [] + + +def test_strict_reraises_a_credentials_failure(): + """Credentials are load-bearing: without them there is no venue at all.""" + client = FakeClient( + credentials_error=RuntimeError("server down"), + networks={"networks": ["solana-mainnet-beta"]}, + ) + with pytest.raises(RuntimeError): + asyncio.run(fetch_venues(client, strict=True)) + + +def test_non_strict_credentials_failure_degrades_to_the_gateway_half(): + venues = _by_name( + asyncio.run( + fetch_venues( + FakeClient( + credentials_error=RuntimeError("server down"), + networks={"networks": ["solana-mainnet-beta"]}, + ) + ) + ) + ) + assert venues["solana-mainnet-beta"]["clmm_lp"] is True + + +def test_rejects_an_unsafe_account_name(): + from condor.fetchers._identifiers import IdentifierError + + with pytest.raises(IdentifierError): + asyncio.run(fetch_venues(FakeClient(), account_name="../etc")) + + +# ── The REST route ──────────────────────────────────────────────────────────── +# +# The panel's connector dropdown is this endpoint's answer, so a server that +# cannot be described must degrade to an empty list rather than a 502. + + +class _FakeSds: + """Stands in for ServerDataService.get_or_fetch.""" + + def __init__(self, value=None, error=None): + self.value = value + self.error = error + self.calls = 0 + + async def get_or_fetch(self, name, data_type, **params): + self.calls += 1 + if self.error is not None: + raise self.error + return self.value + + +def _call_route(monkeypatch, sds): + from condor import server_data_service + from condor.web.models import WebUser + from condor.web.routes.market import get_venues + + monkeypatch.setattr( + server_data_service, "get_server_data_service", lambda: sds, raising=True + ) + + class _Cm: + def has_server_access(self, user_id, name): + return True + + monkeypatch.setattr( + "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True + ) + return asyncio.run(get_venues("srv", user=WebUser(id=1, role="user"))) + + +def test_route_returns_the_venues(monkeypatch): + payload = [ + {"name": "binance", "hummingbot_market_data": True, "clmm_lp": False}, + { + "name": "solana-mainnet-beta", + "hummingbot_market_data": False, + "clmm_lp": True, + }, + ] + assert _call_route(monkeypatch, _FakeSds(value=payload)) == {"venues": payload} + + +def test_route_returns_an_empty_list_when_the_server_is_undescribable(monkeypatch): + """Not a 500: the panel renders its empty state.""" + sds = _FakeSds(error=RuntimeError("server down")) + assert _call_route(monkeypatch, sds) == {"venues": []} + + +def test_route_normalizes_a_none_answer(monkeypatch): + assert _call_route(monkeypatch, _FakeSds(value=None)) == {"venues": []} + + +def test_route_rejects_a_caller_without_access(monkeypatch): + from fastapi import HTTPException + + from condor.web.models import WebUser + from condor.web.routes.market import get_venues + + class _Cm: + def has_server_access(self, user_id, name): + return False + + monkeypatch.setattr( + "condor.web.routes.market.get_config_manager", lambda: _Cm(), raising=True + ) + with pytest.raises(HTTPException) as e: + asyncio.run(get_venues("srv", user=WebUser(id=1, role="user"))) + assert e.value.status_code == 403 From 5cc1fb30f8cec7bf08aa721fdc2aa70b590887f4 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 17:27:20 +0300 Subject: [PATCH 007/116] Decide the trade panel from venue traits, not from DEX-ness connector-capabilities.ts keeps its role -- the one place that maps facts to UI decisions -- but the input becomes traits instead of a `kind: "cex" | "dex"` binary, and each UI consequence is traced to the trait that justifies it. `hummingbotMarketData` grants the order book, the trading rules, REST prices, the LIMIT family and the position/grid/dca tabs; `clmmLp` grants the LP tab, read independently, which is what stops an order-book venue from being offered an LP tab and a swap venue from being denied one. The four conflated call sites in CreateExecutor now each read the trait they actually need: useLpConfig takes `supportsLp` instead of `kind === "dex"`, the price query and the price source take `hasRestPrice` instead of `kind`, and PriceTicker takes `hasRestPrice` instead of standing in order-book-ness for it. Two connector queries collapse to one: the server dedups with a defined winner, so the merge and the `binance-smart-chain`-could-be-in-both worry both go away. Order-book venues are listed first so the reset fallback stays a tradable venue. ExchangeSelector's DEX grouping is fed by `!hummingbotMarketData`, so `xrpl` sits in the exchange group and reads as `Xrpl` -- which is how you actually trade it. An unknown venue, and every venue before the query resolves, keeps full Hummingbot capabilities: the pending state must not flash DEX-restricted UI. Verified offline with a throwaway tsx script (34 assertions, the frontend has no test runner and this is not the feature that adds one): xrpl present in both server input lists yields all four tabs, all four strategies, REST prices and no LP tab; a chartable gateway network yields Order+LP, MARKET only, no book; binance is byte-identical to before. --- .../components/market/ExchangeSelector.tsx | 7 +- frontend/src/lib/api.ts | 23 ++++- frontend/src/lib/connector-capabilities.ts | 99 ++++++++++++------- frontend/src/pages/CreateExecutor.tsx | 75 +++++++------- 4 files changed, 128 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/market/ExchangeSelector.tsx b/frontend/src/components/market/ExchangeSelector.tsx index 6eac9957..8d394e63 100644 --- a/frontend/src/components/market/ExchangeSelector.tsx +++ b/frontend/src/components/market/ExchangeSelector.tsx @@ -5,7 +5,12 @@ interface ExchangeSelectorProps { connectors: string[]; value: string; onChange: (v: string) => void; - /** Connectors that are gateway DEX networks — listed under their own heading. */ + /** + * Venues with no Hummingbot market feed — listed under their own heading and + * formatted as chain ids. Fed by `!hummingbotMarketData`, not by gateway-network + * membership: `xrpl` is a gateway-adjacent id that trades through a real order + * book, so it belongs in the exchange group and reads as `Xrpl`. + */ dexConnectors?: string[]; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9982cd7b..2cb06de5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -267,6 +267,13 @@ export interface MarketPrice { * a pool address by hand. `current_price` is the base priced in quote — the scale * every LP bound is expressed in — and is null when GeckoTerminal reports no price. */ +/** Wire shape of `GET /market/venues`; mapped to `VenueTraits` on the way in. */ +interface WireVenue { + name: string; + hummingbot_market_data: boolean; + clmm_lp: boolean; +} + export interface DexPoolInfo { pool_address: string | null; dex_id: string | null; @@ -1198,11 +1205,17 @@ export const api = { getConnectedExchanges: (server: string) => apiFetch(`/api/v1/servers/${encodeURIComponent(server)}/market/connected-exchanges`), - /** Chartable gateway DEX networks — the trade panel's DEX half of the dropdown. */ - getGatewayNetworks: (server: string) => - apiFetch<{ networks: string[] }>( - `/api/v1/servers/${encodeURIComponent(server)}/market/gateway-networks`, - ).then((r) => r.networks), + /** Venues the trade panel can offer, with the traits each UI decision rests on. */ + getVenues: (server: string) => + apiFetch<{ venues: WireVenue[] }>( + `/api/v1/servers/${encodeURIComponent(server)}/market/venues`, + ).then((r) => + (r.venues ?? []).map((v) => ({ + name: v.name, + hummingbotMarketData: !!v.hummingbot_market_data, + clmmLp: !!v.clmm_lp, + })), + ), getDexPool: (server: string, connector: string, pair: string) => apiFetch( diff --git a/frontend/src/lib/connector-capabilities.ts b/frontend/src/lib/connector-capabilities.ts index 3d79c2fa..6939b796 100644 --- a/frontend/src/lib/connector-capabilities.ts +++ b/frontend/src/lib/connector-capabilities.ts @@ -1,26 +1,38 @@ import type { ExecutorType } from "@/components/executor/types"; /** - * What a connector can do in the trade panel. + * What a venue can do in the trade panel. * - * DEX-ness is **not** guessed from the name here. The backend already carries four - * mutually inconsistent connector predicates (prefix lists, substring lists, live - * gateway lookups) that disagree on cases like `binance-smart-chain`; a fifth one - * in the frontend would silently drift the same way, and the symptom would be "the - * chart is blank and Grid is offered on a DEX". Instead the server tells us which - * connectors are gateway networks (`GET /market/gateway-networks`, already - * intersected with the set Condor can chart) and this module classifies by - * membership in that list. + * This is the one place that maps *facts about a venue* to *UI decisions*, and the + * facts arrive from the server as independent traits (`GET /market/venues`) rather + * than being guessed here. Nothing about a venue is inferred from its name: the + * backend already carries four mutually inconsistent connector predicates (prefix + * lists, substring lists, live gateway lookups) that disagree on cases like + * `binance-smart-chain`, and a fifth one in the frontend would drift the same way. + * + * It is also not inferred from *one* trait standing in for several. The panel asks + * four different questions — which executor tabs exist, which execution strategies + * exist, whether the Hummingbot market endpoints are called, and where the current + * price comes from — and they do not have a common answer. `xrpl` is the venue that + * proves it: a first-class Hummingbot connector with a real order book whose charts + * nonetheless come from GeckoTerminal. A `cex`/`dex` binary keyed on chart source + * silently strips its book; two independent traits cannot. * * The executor-type and strategy maps below *are* frontend literals, deliberately: * they are a UI product decision about which tabs and options to render, not a * server fact, and they are changed by whoever changes the tabs. */ -export type ConnectorKind = "cex" | "dex"; +/** A venue's independent traits, as `GET /market/venues` reports them. */ +export interface VenueTraits { + name: string; + /** `/market/trading-rules`, `/market/order-book`, `/market/tickers`, `/market/prices` answer. */ + hummingbotMarketData: boolean; + /** This venue can take an `lp_executor` CLMM position. */ + clmmLp: boolean; +} export interface ConnectorCapabilities { - kind: ConnectorKind; /** Executor types this venue supports. */ executorTypes: ExecutorType[]; /** Allowed `execution_strategy` values (subset of OrderConfigPanel's options). */ @@ -29,37 +41,56 @@ export interface ConnectorCapabilities { hasOrderBook: boolean; /** Whether `/market/trading-rules` answers — pair list + price precision. */ hasTradingRules: boolean; + /** Whether `/market/prices` answers — otherwise the price comes off the candle stream. */ + hasRestPrice: boolean; + /** Whether the LP tab is offered. */ + supportsLp: boolean; } -const CEX_CAPABILITIES: ConnectorCapabilities = { - kind: "cex", - executorTypes: ["order", "position", "grid", "dca"], - orderStrategies: ["MARKET", "LIMIT", "LIMIT_MAKER", "LIMIT_CHASER"], - hasOrderBook: true, - hasTradingRules: true, -}; - -// A gateway swap has no resting order book to post to, so MARKET is the only -// execution strategy that means anything. `lp` is the CLMM liquidity position — -// the second, and only other, executor a gateway network supports. -const DEX_CAPABILITIES: ConnectorCapabilities = { - kind: "dex", - executorTypes: ["order", "lp"], - orderStrategies: ["MARKET"], - hasOrderBook: false, - hasTradingRules: false, +/** + * The traits assumed for a venue we have no answer for — including every venue + * before the query resolves. Full Hummingbot capabilities: the status quo for every + * venue the panel handled before DEX support existed, so the pending state never + * flashes DEX-restricted UI. + */ +const UNKNOWN_VENUE: VenueTraits = { + name: "", + hummingbotMarketData: true, + clmmLp: false, }; /** - * Capabilities of `connector`, given the gateway networks this server exposes. + * Capabilities of `connector`, given what the server says about the venues. * - * An unknown connector — or any connector at all before the gateway-networks - * query resolves — is treated as a CEX, which is the status quo behavior for - * every venue the panel handled before DEX support existed. + * Each UI consequence is traced to the single trait that justifies it, and the two + * traits are read independently — that is what stops an order-book venue from being + * offered an LP tab, and a swap venue from being denied one. */ export function connectorCapabilities( connector: string, - gatewayNetworks: string[], + venues: VenueTraits[] | Map, ): ConnectorCapabilities { - return gatewayNetworks.includes(connector) ? DEX_CAPABILITIES : CEX_CAPABILITIES; + const traits = + (venues instanceof Map + ? venues.get(connector) + : venues.find((v) => v.name === connector)) ?? UNKNOWN_VENUE; + + // Without a Hummingbot market feed there is no resting order book to post to, so + // MARKET is the only execution strategy that means anything, and the executors + // that manage resting orders (position/grid/dca) have nothing to manage. + const executorTypes: ExecutorType[] = traits.hummingbotMarketData + ? ["order", "position", "grid", "dca"] + : ["order"]; + if (traits.clmmLp) executorTypes.push("lp"); + + return { + executorTypes, + orderStrategies: traits.hummingbotMarketData + ? ["MARKET", "LIMIT", "LIMIT_MAKER", "LIMIT_CHASER"] + : ["MARKET"], + hasOrderBook: traits.hummingbotMarketData, + hasTradingRules: traits.hummingbotMarketData, + hasRestPrice: traits.hummingbotMarketData, + supportsLp: traits.clmmLp, + }; } diff --git a/frontend/src/pages/CreateExecutor.tsx b/frontend/src/pages/CreateExecutor.tsx index 3603bdfc..d58b41db 100644 --- a/frontend/src/pages/CreateExecutor.tsx +++ b/frontend/src/pages/CreateExecutor.tsx @@ -147,42 +147,46 @@ export function CreateExecutor() { cursor: "row-resize", }); - const { data: connectors = [], isPending: connectorsPending } = useQuery({ - queryKey: ["connected-exchanges", server], - queryFn: () => api.getConnectedExchanges(server!), - enabled: !!server, - }); - - // `connected-exchanges` is CEX-only by contract and has other callers, so the - // gateway networks arrive on their own endpoint and the panel merges the two. - const { data: gatewayNetworks = [], isPending: networksPending } = useQuery({ - queryKey: ["gateway-networks", server], - queryFn: () => api.getGatewayNetworks(server!), + // One query, one answer: every venue the panel can offer, each with the traits + // the UI decisions below rest on. The server dedups (a venue in both of its input + // lists is a Hummingbot connector), so there is no merge to get wrong here. + const { data: venues = [], isPending: venuesPending } = useQuery({ + queryKey: ["venues", server], + queryFn: () => api.getVenues(server!), enabled: !!server, staleTime: 5 * 60 * 1000, }); - // Both halves of the dropdown have to be in before the panel may *correct* a - // selection: judging a persisted DEX network against the CEX list alone would - // bounce it to connectors[0] on every reload, and switch its tab to Order. - const listsReady = !!server && !connectorsPending && !networksPending; + // The list has to be in before the panel may *correct* a selection: judging a + // persisted venue against an empty list would bounce it on every reload and + // switch its tab to Order. + const listsReady = !!server && !venuesPending; - // Deduplicated: `is_cex_connector` still calls `binance-smart-chain` a CEX, so a - // network can legitimately appear in both lists. + // Order-book venues first, then swap-only ones — the grouping ExchangeSelector + // renders, and it keeps allConnectors[0] (the reset fallback) a tradable venue + // rather than whichever chain sorts first alphabetically. const allConnectors = useMemo( - () => [...new Set([...connectors, ...gatewayNetworks])], - [connectors, gatewayNetworks], + () => [ + ...venues.filter((v) => v.hummingbotMarketData).map((v) => v.name), + ...venues.filter((v) => !v.hummingbotMarketData).map((v) => v.name), + ], + [venues], + ); + + const dexConnectors = useMemo( + () => venues.filter((v) => !v.hummingbotMarketData).map((v) => v.name), + [venues], ); const caps = useMemo( - () => connectorCapabilities(connector, gatewayNetworks), - [connector, gatewayNetworks], + () => connectorCapabilities(connector, venues), + [connector, venues], ); - // Pool resolution only means something for a gateway network, so the query is off - // for a CEX rather than asking about a pair that has no pool. Declared above the - // connector/pair propagation effects that dispatch into it. - const lpConfig = useLpConfig(server ?? null, connector, pair, caps.kind === "dex"); + // Pool resolution only means something where an LP position can exist, so the + // query is off elsewhere rather than asking about a pair that has no pool. + // Declared above the connector/pair propagation effects that dispatch into it. + const lpConfig = useLpConfig(server ?? null, connector, pair, caps.supportsLp); // WS for executor data (candle streams are managed by candleStore) const wsChannels = useMemo( @@ -218,8 +222,8 @@ export function CreateExecutor() { setSelectedExecutorId(null); }, [connector, pair]); - // Sync connector to the merged list. Validating against the CEX list alone would - // bounce a selected DEX network back to connectors[0] on every render. + // Sync connector to the offered venues. A venue the server no longer reports + // cannot stay selected, or the panel queries endpoints for a venue that is gone. useEffect(() => { if (listsReady && allConnectors.length && !allConnectors.includes(connector)) { gridDispatch({ type: "SET_CONNECTOR", value: allConnectors[0] }); @@ -260,14 +264,14 @@ export function CreateExecutor() { lpConfig.dispatch({ type: "SET_PAIR", value: pair }); }, [pair]); // eslint-disable-line react-hooks/exhaustive-deps - // Current price. /market/prices is a Hummingbot API call with no DEX answer, so a - // gateway network reads the last close off the candle stream instead. The store is + // Current price. /market/prices only answers for a Hummingbot connector, so a + // venue without that trait reads the last close off the candle stream instead. The store is // a singleton over one shared channel and TradeChart already subscribes with these // exact arguments, so this costs no extra connection. const { data: priceData } = useQuery({ queryKey: ["price", server, connector, pair], queryFn: () => api.getPrice(server!, connector, pair), - enabled: !!server && !!connector && !!pair && caps.kind === "cex", + enabled: !!server && !!connector && !!pair && caps.hasRestPrice, refetchInterval: 5000, }); @@ -278,10 +282,9 @@ export function CreateExecutor() { gridState.interval, ); - const currentPrice = - caps.kind === "dex" - ? (sharedCandles[sharedCandles.length - 1]?.close ?? null) - : (priceData?.mid_price ?? null); + const currentPrice = !caps.hasRestPrice + ? (sharedCandles[sharedCandles.length - 1]?.close ?? null) + : (priceData?.mid_price ?? null); // Price precision @@ -455,14 +458,14 @@ export function CreateExecutor() { connectors={allConnectors} value={connector} onChange={(v) => gridDispatch({ type: "SET_CONNECTOR", value: v })} - dexConnectors={gatewayNetworks} + dexConnectors={dexConnectors} /> {/* Price ticker */}
- +
{/* Interval + Range */} From 3fa941fa3e49fb647b94916b92d11e481e7b7fdd Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:01:14 +0300 Subject: [PATCH 008/116] (style) mechanical format sweep: isort then black over the Python tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CLAUDE.md` documents `uv run black .` and `uv run isort .` as this repo's format commands, but neither passed on a clean tree: 69 files failed `black --check` and 42 failed `isort --check-only`. That made the check ungateable — any change touching a drifted file had to choose between burying its real diff in unrelated reflow or leaving the repo's own documented command failing, and every item in the 2026-08-06 sweep chose the second. This commit is `uv run isort .` followed by `uv run black .` and nothing else, so its diff is trivially reviewable as pure reflow and `git blame` readers can skip it. isort runs first so black settles the result. Every hunk is whitespace, wrapping or import ordering. Verified by comparing the AST of each file before and after: the nine files whose node sets differ at all differ only by isort splitting a combined `from X import (a as b, c)` into one statement per name, or reordering names — including one function-level import in `condor/web/routes/settings.py`. No non-import node changed anywhere. `uv run pytest` is 1538 passed before and after, and both `--check` commands now exit 0. READ-133 --- .../routines/hip3_dn_pair_monitor.py | 1 + .../routines/hip3_pairs_backtest.py | 3 + .../routines/ema_research_charts.py | 177 ++++++++++--- .../routines/hip3_market_scanner.py | 1 + .../routines/market_analyzer.py | 5 +- .../routines/mm_dashboard.py | 2 + .../routines/damm_v2_scanner.py | 119 ++++++--- .../routines/easya_graduation_monitor.py | 122 ++++++--- .../routines/launch_safety_check.py | 129 +++++++-- .../smart_money_flow/routines/onchain_flow.py | 137 +++++++--- .../routines/lp_scanner.py | 4 +- .../routines/xrpl_mm_quote_planner.py | 95 +++++-- condor/acp/__init__.py | 10 +- condor/acp/jsonrpc.py | 19 +- condor/backtest_store.py | 21 +- condor/cache.py | 4 +- condor/fetchers/__init__.py | 28 +- condor/fetchers/connectors.py | 8 +- condor/fetchers/executors.py | 16 +- condor/fetchers/trading_rules.py | 7 +- condor/persistence.py | 4 +- condor/preferences.py | 3 +- condor/web/routes/archived.py | 66 +++-- condor/web/routes/executors.py | 33 ++- condor/web/routes/positions.py | 19 +- condor/web/routes/reports.py | 2 +- condor/web/routes/settings.py | 93 ++++--- condor/web/routes/transcribe.py | 3 +- handlers/admin/__init__.py | 6 +- handlers/admin/update.py | 80 ++++-- handlers/agents/openrouter_models.py | 4 +- handlers/bots/__init__.py | 4 +- handlers/bots/controller_handlers.py | 9 +- handlers/bots/menu.py | 4 +- handlers/config/gateway/menu.py | 3 +- handlers/config/gateway/networks.py | 14 +- handlers/config/gateway/pools.py | 11 +- handlers/config/gateway/rpc_providers.py | 250 +++++++++++------- handlers/config/gateway/tokens.py | 14 +- handlers/config/gateway/wallets.py | 18 +- handlers/delegations.py | 6 +- handlers/dex/_shared.py | 26 +- handlers/executors/__init__.py | 18 +- handlers/executors/grid.py | 28 +- handlers/executors/menu.py | 38 ++- handlers/executors/position.py | 73 +++-- mcp_servers/condor/tools/context.py | 9 +- mcp_servers/condor/tools/servers.py | 16 +- .../hummingbot_api/executor_preferences.py | 33 ++- .../hummingbot_api/formatters/__init__.py | 28 +- mcp_servers/hummingbot_api/formatters/base.py | 19 +- mcp_servers/hummingbot_api/formatters/bots.py | 30 ++- .../hummingbot_api/formatters/executors.py | 90 +++++-- .../hummingbot_api/formatters/gateway.py | 12 +- .../hummingbot_api/formatters/market_data.py | 1 + .../hummingbot_api/formatters/portfolio.py | 15 +- .../formatters/table_builder.py | 26 +- .../hummingbot_api/formatters/trading.py | 30 ++- .../hummingbot_api/hummingbot_client.py | 22 +- mcp_servers/hummingbot_api/middleware.py | 17 +- mcp_servers/hummingbot_api/schemas.py | 71 +++-- mcp_servers/hummingbot_api/server.py | 4 +- .../hummingbot_api/tools/bot_management.py | 58 +++- .../hummingbot_api/tools/controllers.py | 128 ++++++--- mcp_servers/hummingbot_api/tools/executors.py | 92 +++++-- mcp_servers/hummingbot_api/tools/gateway.py | 129 +++++---- .../hummingbot_api/tools/gateway_amm.py | 69 +++-- .../hummingbot_api/tools/gateway_clmm.py | 35 +-- .../hummingbot_api/tools/gateway_swap.py | 36 +-- .../hummingbot_api/tools/geckoterminal.py | 139 +++++++--- mcp_servers/hummingbot_api/tools/history.py | 46 ++-- .../hummingbot_api/tools/market_data.py | 5 +- mcp_servers/hummingbot_api/tools/portfolio.py | 217 +++++++++------ mcp_servers/hummingbot_api/tools/trading.py | 10 +- routines/market_scanner.py | 1 + tests/test_custom_provider.py | 32 ++- tests/test_executor_mutation_errors.py | 5 +- tests/test_executor_row_shared.py | 5 +- tests/test_executors_period_summary.py | 19 +- tests/test_instance_history_pagination.py | 4 +- tests/test_reports_attribution.py | 4 +- utils/auth.py | 6 +- utils/deeplink.py | 16 +- utils/transcribe.py | 9 +- utils/updater.py | 47 +++- 85 files changed, 2210 insertions(+), 1062 deletions(-) diff --git a/agents/delta_neutral_funding_agent/routines/hip3_dn_pair_monitor.py b/agents/delta_neutral_funding_agent/routines/hip3_dn_pair_monitor.py index ccdda545..ed131e1e 100644 --- a/agents/delta_neutral_funding_agent/routines/hip3_dn_pair_monitor.py +++ b/agents/delta_neutral_funding_agent/routines/hip3_dn_pair_monitor.py @@ -8,6 +8,7 @@ import aiohttp from pydantic import BaseModel, Field from telegram.ext import ContextTypes + from config_manager import get_client logger = logging.getLogger(__name__) diff --git a/agents/delta_neutral_funding_agent/routines/hip3_pairs_backtest.py b/agents/delta_neutral_funding_agent/routines/hip3_pairs_backtest.py index 3e12513b..0a217535 100644 --- a/agents/delta_neutral_funding_agent/routines/hip3_pairs_backtest.py +++ b/agents/delta_neutral_funding_agent/routines/hip3_pairs_backtest.py @@ -8,6 +8,7 @@ import aiohttp from pydantic import BaseModel, Field from telegram.ext import ContextTypes + from config_manager import get_client logger = logging.getLogger(__name__) @@ -889,6 +890,7 @@ def _fmt_signal(name: str, r: dict, flags: list) -> str: # ── 7. ReportBuilder ────────────────────────────────────────────────────── import plotly.graph_objects as go + from condor.reports import ReportBuilder builder = ReportBuilder( @@ -1258,6 +1260,7 @@ def _fmt(label, r, beta, r2): # ── 9. ReportBuilder ────────────────────────────────────────────────────── import plotly.graph_objects as go + from condor.reports import ReportBuilder builder = ReportBuilder(f"HIP-3 Pairs Backtest: {issuer_upper}") diff --git a/agents/directional_trader/routines/ema_research_charts.py b/agents/directional_trader/routines/ema_research_charts.py index 45b6a81e..b6bb0106 100644 --- a/agents/directional_trader/routines/ema_research_charts.py +++ b/agents/directional_trader/routines/ema_research_charts.py @@ -26,27 +26,41 @@ # bars per calendar day for each supported timeframe BARS_PER_DAY = { - "1m": 1440, "3m": 480, "5m": 288, "15m": 96, - "30m": 48, "1h": 24, "4h": 6, "1d": 1, + "1m": 1440, + "3m": 480, + "5m": 288, + "15m": 96, + "30m": 48, + "1h": 24, + "4h": 6, + "1d": 1, } class Config(BaseModel): """EMA trend research — sweep EMA combos, score by Sharpe × persistence, show current signal state.""" - connector: str = Field(default="binance_perpetual", description="Exchange connector") + connector: str = Field( + default="binance_perpetual", description="Exchange connector" + ) pairs: str = Field( default="BTC-USDT,ETH-USDT,SOL-USDT", description="Comma-separated trading pairs", ) - timeframe: str = Field(default="15m", description="Candle interval (1m/3m/15m/1h/4h)") + timeframe: str = Field( + default="15m", description="Candle interval (1m/3m/15m/1h/4h)" + ) days: int = Field(default=30, description="Days of history to fetch") - hold_bars: int = Field(default=5, description="Bars ahead to measure post-signal move") + hold_bars: int = Field( + default=5, description="Bars ahead to measure post-signal move" + ) fast_min: int = Field(default=5, description="Min fast EMA period") fast_max: int = Field(default=21, description="Max fast EMA period") slow_min: int = Field(default=21, description="Min slow EMA period") slow_max: int = Field(default=89, description="Max slow EMA period") - adx_filter: int = Field(default=0, description="Min ADX for a signal to count (0=disabled)") + adx_filter: int = Field( + default=0, description="Min ADX for a signal to count (0=disabled)" + ) top_n: int = Field(default=5, description="Top N combos to show per pair") @@ -308,7 +322,9 @@ def _grid(lo, hi, n=5): # ── Fetch ───────────────────────────────────────────────────────────────── data = {} for pair in pairs: - df = await _fetch_df(client, config.connector, pair, config.timeframe, config.days) + df = await _fetch_df( + client, config.connector, pair, config.timeframe, config.days + ) if df is not None and len(df) >= min_bars: data[pair] = df logger.info(f" {pair}/{config.timeframe}: {len(df)} candles") @@ -344,7 +360,9 @@ def _grid(lo, hi, n=5): "adx_pct_trending": float((adx_s > 25).mean() * 100), "autocorr_lag1": round(autocorr, 4), "regime": ( - "trend" if autocorr > 0.05 else ("mean-rev" if autocorr < -0.05 else "random") + "trend" + if autocorr > 0.05 + else ("mean-rev" if autocorr < -0.05 else "random") ), } @@ -371,9 +389,7 @@ def _grid(lo, hi, n=5): top3 = all_scored[:3] best_per_pair = { - pair: sweep_results[pair][0] - for pair in active_pairs - if sweep_results.get(pair) + pair: sweep_results[pair][0] for pair in active_pairs if sweep_results.get(pair) } current_signals = { pair: { @@ -388,7 +404,9 @@ def _grid(lo, hi, n=5): fig.patch.set_facecolor("#0f172a") fig.suptitle( f"Returns Distribution & Realized Volatility — {config.days}d, {config.timeframe}", - fontsize=13, fontweight="bold", color="white", + fontsize=13, + fontweight="bold", + color="white", ) for ci, pair in enumerate(active_pairs): df = data[pair] @@ -399,21 +417,31 @@ def _grid(lo, hi, n=5): ax_h = axes[0, ci] _style_ax(ax_h) ax_h.hist(ret, bins=100, color=color, alpha=0.75, density=True) - ax_h.axvline(ret.mean(), color="white", linestyle="--", linewidth=1, - label=f"μ={ret.mean():.4f}%") + ax_h.axvline( + ret.mean(), + color="white", + linestyle="--", + linewidth=1, + label=f"μ={ret.mean():.4f}%", + ) ax_h.set_title( f"{pair}\nskew={s['ret_skew']:.2f} kurt={s['ret_kurt']:.2f}", - fontsize=9, color="white", + fontsize=9, + color="white", ) ax_h.set_xlabel("Return %", color="white", fontsize=7) ax_h.legend(fontsize=7, facecolor="#1e293b", labelcolor="white") ax_v = axes[1, ci] _style_ax(ax_v) - rvol = (ret.rolling(bars_per_day * 5).std() * math.sqrt(252 * bars_per_day)).dropna() - dt_slice = df["datetime"].iloc[-len(rvol):] + rvol = ( + ret.rolling(bars_per_day * 5).std() * math.sqrt(252 * bars_per_day) + ).dropna() + dt_slice = df["datetime"].iloc[-len(rvol) :] ax_v.plot(dt_slice.values, rvol.values, color=color, linewidth=0.8) - ax_v.set_title(f"{pair} Ann. RVol\nμ={rvol.mean():.1f}%", fontsize=9, color="white") + ax_v.set_title( + f"{pair} Ann. RVol\nμ={rvol.mean():.1f}%", fontsize=9, color="white" + ) ax_v.set_ylabel("RVol %", color="white", fontsize=7) ax_v.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d")) ax_v.tick_params(axis="x", rotation=45) @@ -431,7 +459,9 @@ def _grid(lo, hi, n=5): fig.patch.set_facecolor("#0f172a") fig.suptitle( f"ADX Distribution — Trend Regime ({config.days}d, {config.timeframe})", - fontsize=13, fontweight="bold", color="white", + fontsize=13, + fontweight="bold", + color="white", ) for ci, pair in enumerate(active_pairs): df = data[pair] @@ -444,12 +474,16 @@ def _grid(lo, hi, n=5): ax.hist(adx_s, bins=60, color=color, alpha=0.75) ax.axvline(25, color="#ef4444", linestyle="--", linewidth=1.2, label="ADX=25") ax.axvline( - float(adx_s.median()), color="white", linestyle="-.", linewidth=1, + float(adx_s.median()), + color="white", + linestyle="-.", + linewidth=1, label=f"median={adx_s.median():.1f}", ) ax.set_title( f"{pair}\n{s['adx_pct_trending']:.0f}% time ADX>25", - fontsize=9, color="white", + fontsize=9, + color="white", ) ax.legend(fontsize=7, facecolor="#1e293b", labelcolor="white") ax.set_xlabel("ADX", color="white", fontsize=7) @@ -467,7 +501,9 @@ def _grid(lo, hi, n=5): fig.patch.set_facecolor("#0f172a") fig.suptitle( f"Best EMA Combo per Pair — Last 500 bars ({config.timeframe})", - fontsize=12, fontweight="bold", color="white", + fontsize=12, + fontweight="bold", + color="white", ) for ri, pair in enumerate(active_pairs): if pair not in best_per_pair: @@ -482,26 +518,59 @@ def _grid(lo, hi, n=5): ax = axes[ri, 0] _style_ax(ax) - ax.plot(df_c["datetime"], df_c["close"], color="white", linewidth=0.6, - label="Close", zorder=2) - ax.plot(df_c["datetime"], df_c["ema_f"], color="#3b82f6", linewidth=1.3, - label=f"EMA{fp}", zorder=3) - ax.plot(df_c["datetime"], df_c["ema_s"], color="#f59e0b", linewidth=1.3, - label=f"EMA{sp}", zorder=3) + ax.plot( + df_c["datetime"], + df_c["close"], + color="white", + linewidth=0.6, + label="Close", + zorder=2, + ) + ax.plot( + df_c["datetime"], + df_c["ema_f"], + color="#3b82f6", + linewidth=1.3, + label=f"EMA{fp}", + zorder=3, + ) + ax.plot( + df_c["datetime"], + df_c["ema_s"], + color="#f59e0b", + linewidth=1.3, + label=f"EMA{sp}", + zorder=3, + ) longs = df_c[df_c["cross"] & (df_c["sig"] == 1)] shorts = df_c[df_c["cross"] & (df_c["sig"] == -1)] - ax.scatter(longs["datetime"], longs["close"], marker="^", color="#22c55e", - s=50, zorder=5, label="Long") - ax.scatter(shorts["datetime"], shorts["close"], marker="v", color="#ef4444", - s=50, zorder=5, label="Short") + ax.scatter( + longs["datetime"], + longs["close"], + marker="^", + color="#22c55e", + s=50, + zorder=5, + label="Long", + ) + ax.scatter( + shorts["datetime"], + shorts["close"], + marker="v", + color="#ef4444", + s=50, + zorder=5, + label="Short", + ) sig_label = "▲ LONG" if best["current_signal"] == 1 else "▼ SHORT" ax.set_title( f"{pair} {config.timeframe} | EMA{fp}/{sp} | score={best['score']:.3f} | " f"win={best['win_rate']:.0f}% | sharpe={best['sharpe']:.2f} | " f"persist={best['avg_persistence']:.0f}b | NOW: {sig_label}", - fontsize=9, color="white", + fontsize=9, + color="white", ) ax.legend(fontsize=8, facecolor="#1e293b", labelcolor="white") ax.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d %H:%M")) @@ -540,7 +609,8 @@ def _grid(lo, hi, n=5): ax.set_ylabel("Fast EMA", color="white", fontsize=9) ax.set_title( f"{pair_hm} — EMA Score Heatmap (Sharpe × Persistence)", - fontsize=11, color="white", + fontsize=11, + color="white", ) cbar = plt.colorbar(im, ax=ax) cbar.ax.tick_params(colors="white") @@ -550,11 +620,18 @@ def _grid(lo, hi, n=5): bfi, bsi = fi.get(best_hm["fast"]), si.get(best_hm["slow"]) if bfi is not None and bsi is not None: ax.add_patch( - plt.Rectangle((bsi - 0.5, bfi - 0.5), 1, 1, fill=False, - edgecolor="white", linewidth=2) + plt.Rectangle( + (bsi - 0.5, bfi - 0.5), + 1, + 1, + fill=False, + edgecolor="white", + linewidth=2, + ) + ) + ax.text( + bsi, bfi, "★", ha="center", va="center", color="white", fontsize=14 ) - ax.text(bsi, bfi, "★", ha="center", va="center", - color="white", fontsize=14) plt.tight_layout() await context.bot.send_photo( @@ -569,7 +646,9 @@ def _grid(lo, hi, n=5): fig.patch.set_facecolor("#0f172a") fig.suptitle( "Normalized Distance: (close − EMA_slow) / ATR(14)", - fontsize=11, fontweight="bold", color="white", + fontsize=11, + fontweight="bold", + color="white", ) for ci, pair in enumerate(active_pairs): if pair not in best_per_pair: @@ -590,7 +669,8 @@ def _grid(lo, hi, n=5): ax.axvline(0, color="white", linestyle="-", linewidth=0.5) ax.set_title( f"{pair} | EMA{sp}\nμ={norm_dist.mean():.2f} σ={norm_dist.std():.2f}", - fontsize=9, color="white", + fontsize=9, + color="white", ) ax.legend(fontsize=7, facecolor="#1e293b", labelcolor="white") ax.set_xlabel("(close − EMA) / ATR", color="white", fontsize=7) @@ -681,12 +761,23 @@ def _grid(lo, hi, n=5): rb.markdown("## Top EMA Combos\n") rb.table( top3, - ["pair", "fast", "slow", "score", "win_rate", "sharpe", - "avg_persistence", "signals_per_day", "current_signal"], + [ + "pair", + "fast", + "slow", + "score", + "win_rate", + "sharpe", + "avg_persistence", + "signals_per_day", + "current_signal", + ], ) if top3: rb.markdown(f"## Recommendation\n\n{lines[-1]}") - rb.tags(["ema", "research", "trend"] + [p.split("-")[0].lower() for p in active_pairs]) + rb.tags( + ["ema", "research", "trend"] + [p.split("-")[0].lower() for p in active_pairs] + ) await rb.save() return summary diff --git a/agents/market_making_expert/routines/hip3_market_scanner.py b/agents/market_making_expert/routines/hip3_market_scanner.py index bb01e222..a41c8544 100644 --- a/agents/market_making_expert/routines/hip3_market_scanner.py +++ b/agents/market_making_expert/routines/hip3_market_scanner.py @@ -7,6 +7,7 @@ import aiohttp from pydantic import BaseModel, Field from telegram.ext import ContextTypes + from config_manager import get_client logger = logging.getLogger(__name__) diff --git a/agents/market_making_expert/routines/market_analyzer.py b/agents/market_making_expert/routines/market_analyzer.py index 8c0d5bab..f9215531 100644 --- a/agents/market_making_expert/routines/market_analyzer.py +++ b/agents/market_making_expert/routines/market_analyzer.py @@ -2,10 +2,12 @@ import logging import math + from pydantic import BaseModel, Field from telegram.ext import ContextTypes -from config_manager import get_client + from condor.reports.footprint import build_estimated_footprint_figure, candle_timestamps +from config_manager import get_client logger = logging.getLogger(__name__) @@ -474,6 +476,7 @@ def _norm(c): # Generate report import plotly.graph_objects as go + from condor.reports import ReportBuilder builder = ReportBuilder(f"Market Analysis: {pair}") diff --git a/agents/market_making_expert/routines/mm_dashboard.py b/agents/market_making_expert/routines/mm_dashboard.py index a1c5cd3f..8f53a950 100644 --- a/agents/market_making_expert/routines/mm_dashboard.py +++ b/agents/market_making_expert/routines/mm_dashboard.py @@ -17,8 +17,10 @@ import logging from collections import Counter from datetime import datetime, timezone + from pydantic import BaseModel, Field from telegram.ext import ContextTypes + from config_manager import get_client logger = logging.getLogger(__name__) diff --git a/agents/meteora_launch_lp/routines/damm_v2_scanner.py b/agents/meteora_launch_lp/routines/damm_v2_scanner.py index 52ba7d53..2bf6b3e6 100644 --- a/agents/meteora_launch_lp/routines/damm_v2_scanner.py +++ b/agents/meteora_launch_lp/routines/damm_v2_scanner.py @@ -14,6 +14,7 @@ The scanner deliberately skips pools with an active fee scheduler (base fee often starts near 99% and decays — a token-launch trap) unless include_launch_pools is set. """ + import logging import aiohttp @@ -39,14 +40,28 @@ class Config(BaseModel): """Rank Meteora DAMM v2 pools by fee yield (fees/TVL) for AMM LP entry.""" - quote_asset: str = Field(default="SOL", description="Quote token to require on one side: SOL, USDC, or USDT") - query: str | None = Field(default=None, description="Optional search (token symbol/name/address), e.g. 'JUP'") - ranking_window: str = Field(default="24h", description="Fee-yield window: 1h, 2h, 4h, 12h, or 24h") + quote_asset: str = Field( + default="SOL", + description="Quote token to require on one side: SOL, USDC, or USDT", + ) + query: str | None = Field( + default=None, + description="Optional search (token symbol/name/address), e.g. 'JUP'", + ) + ranking_window: str = Field( + default="24h", description="Fee-yield window: 1h, 2h, 4h, 12h, or 24h" + ) top_n: int = Field(default=10, description="Number of ranked pools to return") min_tvl_usd: float = Field(default=25000.0, description="Minimum pool TVL in USD") - include_launch_pools: bool = Field(default=False, description="Include active-fee-scheduler (launch) pools") - verified_only: bool = Field(default=True, description="Require both tokens verified") - exclude_pools: list[str] = Field(default=[], description="Pool addresses to exclude (already held)") + include_launch_pools: bool = Field( + default=False, description="Include active-fee-scheduler (launch) pools" + ) + verified_only: bool = Field( + default=True, description="Require both tokens verified" + ) + exclude_pools: list[str] = Field( + default=[], description="Pool addresses to exclude (already held)" + ) async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: @@ -70,7 +85,9 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: try: async with aiohttp.ClientSession() as session: - async with session.get(DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=20)) as resp: + async with session.get( + DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=20) + ) as resp: if resp.status != 200: return f"damm_v2_scanner: Meteora DAMM v2 API returned HTTP {resp.status}" payload = await resp.json() @@ -93,50 +110,69 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: cfg = p.get("pool_config", {}) or {} if not config.include_launch_pools and cfg.get("is_fee_scheduler_active"): continue # skip launch pools whose base fee starts ~99% - if config.verified_only and not (tx.get("is_verified") and ty.get("is_verified")): + if config.verified_only and not ( + tx.get("is_verified") and ty.get("is_verified") + ): continue # Base = the side that is NOT the quote asset. base, quote_tok = (ty, tx) if tx.get("address") == quote_mint else (tx, ty) fee_yield = float((p.get("fee_tvl_ratio") or {}).get(window) or 0) - candidates.append({ - "pool": p.get("address"), - "pair": p.get("name") or f"{base.get('symbol','?')}-{quote_tok.get('symbol','?')}", - "base_symbol": base.get("symbol") or base.get("address", "")[:6], - "quote_symbol": quote_tok.get("symbol") or quote, - "base_mint": base.get("address"), - "quote_mint": quote_tok.get("address"), - "base_fee_pct": float(cfg.get("base_fee_pct") or 0), - "tvl": float(p.get("tvl") or 0), - "vol_win": float((p.get("volume") or {}).get(window) or 0), - "fee_yield": fee_yield, - "price": float(p.get("current_price") or 0), - }) + candidates.append( + { + "pool": p.get("address"), + "pair": p.get("name") + or f"{base.get('symbol','?')}-{quote_tok.get('symbol','?')}", + "base_symbol": base.get("symbol") or base.get("address", "")[:6], + "quote_symbol": quote_tok.get("symbol") or quote, + "base_mint": base.get("address"), + "quote_mint": quote_tok.get("address"), + "base_fee_pct": float(cfg.get("base_fee_pct") or 0), + "tvl": float(p.get("tvl") or 0), + "vol_win": float((p.get("volume") or {}).get(window) or 0), + "fee_yield": fee_yield, + "price": float(p.get("current_price") or 0), + } + ) if not candidates: - return (f"damm_v2_scanner: no {quote}-quoted DAMM v2 pools passed the filters " - f"(min TVL ${config.min_tvl_usd:,.0f}, verified_only={config.verified_only}, " - f"launch_pools={config.include_launch_pools}).") + return ( + f"damm_v2_scanner: no {quote}-quoted DAMM v2 pools passed the filters " + f"(min TVL ${config.min_tvl_usd:,.0f}, verified_only={config.verified_only}, " + f"launch_pools={config.include_launch_pools})." + ) candidates.sort(key=lambda c: c["fee_yield"], reverse=True) ranked = candidates[: config.top_n] - columns = ["#", "Pair", "FeeYield", "BaseFee", "TVL", f"Vol{window}", "Price", "Pool", "BaseMint"] + columns = [ + "#", + "Pair", + "FeeYield", + "BaseFee", + "TVL", + f"Vol{window}", + "Price", + "Pool", + "BaseMint", + ] rows = [] for i, c in enumerate(ranked, 1): - rows.append({ - "#": i, - "Pair": c["pair"], - "FeeYield": f"{c['fee_yield'] * 100:.3f}%", - "BaseFee": f"{c['base_fee_pct']:.3f}%", - "TVL": f"${c['tvl']:,.0f}", - f"Vol{window}": f"${c['vol_win']:,.0f}", - "Price": f"{c['price']:.4g}", - "Pool": c["pool"], - "BaseMint": c["base_mint"], - # manage_amm hints (constant across rows): connector=meteora, network=solana-mainnet-beta. - "quote_mint": c["quote_mint"], - }) + rows.append( + { + "#": i, + "Pair": c["pair"], + "FeeYield": f"{c['fee_yield'] * 100:.3f}%", + "BaseFee": f"{c['base_fee_pct']:.3f}%", + "TVL": f"${c['tvl']:,.0f}", + f"Vol{window}": f"${c['vol_win']:,.0f}", + "Price": f"{c['price']:.4g}", + "Pool": c["pool"], + "BaseMint": c["base_mint"], + # manage_amm hints (constant across rows): connector=meteora, network=solana-mainnet-beta. + "quote_mint": c["quote_mint"], + } + ) top = rows[0] summary = ( @@ -150,10 +186,13 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: try: from routines.base import RoutineResult + return RoutineResult(text=summary, table_data=rows, table_columns=columns) except Exception: lines = [summary, ""] for r in rows: - lines.append(f"{r['#']}. {r['Pair']} | yield {r['FeeYield']} | fee {r['BaseFee']} | " - f"TVL {r['TVL']} | pool {r['Pool']} | base {r['BaseMint']}") + lines.append( + f"{r['#']}. {r['Pair']} | yield {r['FeeYield']} | fee {r['BaseFee']} | " + f"TVL {r['TVL']} | pool {r['Pool']} | base {r['BaseMint']}" + ) return "\n".join(lines) diff --git a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py index 1fb19615..602a726e 100644 --- a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py +++ b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py @@ -14,6 +14,7 @@ Note: early LP here is directional-long the token (you must buy the base token to pair it with SOL), so size small and capped. """ + import logging import time @@ -35,22 +36,40 @@ class Config(BaseModel): """Detect fresh EasyA graduations into Meteora DAMM v2, ranked by fee yield.""" - max_age_hours: float = Field(default=72.0, description="Only pools created within this many hours") - min_tvl_usd: float = Field(default=10000.0, description="Minimum pool TVL in USD (graduation liquidity floor)") - min_vol24h_usd: float = Field(default=3000.0, description="Minimum 24h volume in USD (real post-grad demand)") - verified_only: bool = Field(default=False, description="Require the graduated token to be verified") - require_static_fee: bool = Field(default=True, description="Exclude fee-scheduler pools (EasyA grads are static)") + max_age_hours: float = Field( + default=72.0, description="Only pools created within this many hours" + ) + min_tvl_usd: float = Field( + default=10000.0, + description="Minimum pool TVL in USD (graduation liquidity floor)", + ) + min_vol24h_usd: float = Field( + default=3000.0, description="Minimum 24h volume in USD (real post-grad demand)" + ) + verified_only: bool = Field( + default=False, description="Require the graduated token to be verified" + ) + require_static_fee: bool = Field( + default=True, description="Exclude fee-scheduler pools (EasyA grads are static)" + ) top_n: int = Field(default=15, description="Number of ranked graduations to return") async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: # No created_at sort on the API, so fetch a wide TVL-sorted page and filter locally. # EasyA pools graduate with ~$10k+ TVL, so they are within the top page by TVL. - params = {"page": 1, "page_size": 1000, "sort_by": "tvl:desc", "filter_by": "is_blacklisted=false"} + params = { + "page": 1, + "page_size": 1000, + "sort_by": "tvl:desc", + "filter_by": "is_blacklisted=false", + } try: async with aiohttp.ClientSession() as session: - async with session.get(DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=25)) as resp: + async with session.get( + DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=25) + ) as resp: if resp.status != 200: return f"easya_graduation_monitor: Meteora DAMM v2 API returned HTTP {resp.status}" payload = await resp.json() @@ -84,50 +103,70 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: if float((p.get("volume") or {}).get("24h") or 0) < config.min_vol24h_usd: continue cfg = p.get("pool_config", {}) or {} - if config.require_static_fee and (cfg.get("is_fee_scheduler_active") or cfg.get("has_fee_scheduler")): + if config.require_static_fee and ( + cfg.get("is_fee_scheduler_active") or cfg.get("has_fee_scheduler") + ): continue if config.verified_only and not easy_side.get("is_verified"): continue - candidates.append({ - "pool": p.get("address"), - "pair": p.get("name") or f"{easy_side.get('symbol','?')}-SOL", - "token_symbol": easy_side.get("symbol") or easy_side.get("address", "")[:6], - "base_mint": easy_side.get("address"), - "verified": bool(easy_side.get("is_verified")), - "base_fee_pct": float(cfg.get("base_fee_pct") or 0), - "tvl": float(p.get("tvl") or 0), - "vol24h": float((p.get("volume") or {}).get("24h") or 0), - "fee_yield24h": float((p.get("fee_tvl_ratio") or {}).get("24h") or 0), - "age_h": age_h, - "price": float(p.get("current_price") or 0), - }) + candidates.append( + { + "pool": p.get("address"), + "pair": p.get("name") or f"{easy_side.get('symbol','?')}-SOL", + "token_symbol": easy_side.get("symbol") + or easy_side.get("address", "")[:6], + "base_mint": easy_side.get("address"), + "verified": bool(easy_side.get("is_verified")), + "base_fee_pct": float(cfg.get("base_fee_pct") or 0), + "tvl": float(p.get("tvl") or 0), + "vol24h": float((p.get("volume") or {}).get("24h") or 0), + "fee_yield24h": float((p.get("fee_tvl_ratio") or {}).get("24h") or 0), + "age_h": age_h, + "price": float(p.get("current_price") or 0), + } + ) if not candidates: - return (f"easya_graduation_monitor: no EasyA graduations in the last {config.max_age_hours:.0f}h " - f"passed the filters (min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}, " - f"verified_only={config.verified_only}).") + return ( + f"easya_graduation_monitor: no EasyA graduations in the last {config.max_age_hours:.0f}h " + f"passed the filters (min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}, " + f"verified_only={config.verified_only})." + ) # Rank by traction: 24h fee yield (fees/TVL) — the ones actually earning. candidates.sort(key=lambda c: c["fee_yield24h"], reverse=True) ranked = candidates[: config.top_n] - columns = ["#", "Pair", "Age(h)", "FeeYield", "TVL", "Vol24h", "Verified", "Price", "Pool", "BaseMint"] + columns = [ + "#", + "Pair", + "Age(h)", + "FeeYield", + "TVL", + "Vol24h", + "Verified", + "Price", + "Pool", + "BaseMint", + ] rows = [] for i, c in enumerate(ranked, 1): - rows.append({ - "#": i, - "Pair": c["pair"], - "Age(h)": f"{c['age_h']:.1f}", - "FeeYield": f"{c['fee_yield24h'] * 100:.1f}%", - "TVL": f"${c['tvl']:,.0f}", - "Vol24h": f"${c['vol24h']:,.0f}", - "Verified": "yes" if c["verified"] else "no", - "Price": f"{c['price']:.4g}", - "Pool": c["pool"], - "BaseMint": c["base_mint"], - "quote_mint": SOL_MINT, - }) + rows.append( + { + "#": i, + "Pair": c["pair"], + "Age(h)": f"{c['age_h']:.1f}", + "FeeYield": f"{c['fee_yield24h'] * 100:.1f}%", + "TVL": f"${c['tvl']:,.0f}", + "Vol24h": f"${c['vol24h']:,.0f}", + "Verified": "yes" if c["verified"] else "no", + "Price": f"{c['price']:.4g}", + "Pool": c["pool"], + "BaseMint": c["base_mint"], + "quote_mint": SOL_MINT, + } + ) top = rows[0] summary = ( @@ -141,10 +180,13 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: try: from routines.base import RoutineResult + return RoutineResult(text=summary, table_data=rows, table_columns=columns) except Exception: lines = [summary, ""] for r in rows: - lines.append(f"{r['#']}. {r['Pair']} | age {r['Age(h)']}h | yield {r['FeeYield']} | " - f"TVL {r['TVL']} | vol {r['Vol24h']} | verified {r['Verified']} | pool {r['Pool']}") + lines.append( + f"{r['#']}. {r['Pair']} | age {r['Age(h)']}h | yield {r['FeeYield']} | " + f"TVL {r['TVL']} | vol {r['Vol24h']} | verified {r['Verified']} | pool {r['Pool']}" + ) return "\n".join(lines) diff --git a/agents/meteora_launch_lp/routines/launch_safety_check.py b/agents/meteora_launch_lp/routines/launch_safety_check.py index d7f27d7d..ed228db0 100644 --- a/agents/meteora_launch_lp/routines/launch_safety_check.py +++ b/agents/meteora_launch_lp/routines/launch_safety_check.py @@ -12,6 +12,7 @@ RPC: defaults to the public mainnet endpoint (rate-limited but fine for occasional checks); override with rpc_url for a private endpoint. No credentials are hardcoded. """ + import logging import aiohttp @@ -39,17 +40,32 @@ def _rpc_url_default(cls, v): # Callers (e.g. strategy configs) may pass an empty string meaning "no private RPC" — # fall back to the public endpoint instead of failing every RPC gate on an invalid URL. return v or DEFAULT_RPC - max_top10_holder_pct: float = Field(default=60.0, description="Fail if top-10 holders exceed this % of supply") - require_mint_renounced: bool = Field(default=True, description="Fail if the token mint authority is not renounced") - require_freeze_disabled: bool = Field(default=True, description="Fail if the freeze authority is not disabled") - require_verified: bool = Field(default=False, description="Fail if the token is not verified") - min_lock_pct: float = Field(default=0.0, description="Require locked+vested liquidity ≥ this % of TVL (0 = off)") + + max_top10_holder_pct: float = Field( + default=60.0, description="Fail if top-10 holders exceed this % of supply" + ) + require_mint_renounced: bool = Field( + default=True, description="Fail if the token mint authority is not renounced" + ) + require_freeze_disabled: bool = Field( + default=True, description="Fail if the freeze authority is not disabled" + ) + require_verified: bool = Field( + default=False, description="Fail if the token is not verified" + ) + min_lock_pct: float = Field( + default=0.0, + description="Require locked+vested liquidity ≥ this % of TVL (0 = off)", + ) min_tvl_usd: float = Field(default=10000.0, description="Fail below this TVL") async def _rpc(session, url, method, params): - async with session.post(url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, - timeout=aiohttp.ClientTimeout(total=20)) as r: + async with session.post( + url, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=aiohttp.ClientTimeout(total=20), + ) as r: if r.status != 200: raise RuntimeError(f"RPC {method} -> HTTP {r.status}") data = await r.json() @@ -64,8 +80,11 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: # 1. Pool + token metadata from the Meteora DAMM v2 API. try: async with aiohttp.ClientSession() as session: - async with session.get(DAMM_V2_API, params={"query": config.pool_address, "page_size": 1}, - timeout=aiohttp.ClientTimeout(total=20)) as resp: + async with session.get( + DAMM_V2_API, + params={"query": config.pool_address, "page_size": 1}, + timeout=aiohttp.ClientTimeout(total=20), + ) as resp: if resp.status != 200: return f"launch_safety_check: Meteora API HTTP {resp.status}" pool = (await resp.json()).get("data", [{}])[0] @@ -82,45 +101,99 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: cfg = pool.get("pool_config", {}) or {} # Static gates from the API. - gates.append(("tvl", tvl >= config.min_tvl_usd, f"${tvl:,.0f} (min ${config.min_tvl_usd:,.0f})")) + gates.append( + ( + "tvl", + tvl >= config.min_tvl_usd, + f"${tvl:,.0f} (min ${config.min_tvl_usd:,.0f})", + ) + ) if config.require_verified: - gates.append(("verified", bool(base.get("is_verified")), f"{base.get('symbol')} verified={base.get('is_verified')}")) + gates.append( + ( + "verified", + bool(base.get("is_verified")), + f"{base.get('symbol')} verified={base.get('is_verified')}", + ) + ) if config.require_freeze_disabled: - gates.append(("freeze_disabled", bool(base.get("freeze_authority_disabled")), - f"freeze_authority_disabled={base.get('freeze_authority_disabled')}")) + gates.append( + ( + "freeze_disabled", + bool(base.get("freeze_authority_disabled")), + f"freeze_authority_disabled={base.get('freeze_authority_disabled')}", + ) + ) if config.min_lock_pct > 0: locked = float(pool.get("permanent_lock_liquidity") or 0) - vested = sum(float(v or 0) for v in (pool.get("vested_liquidity") or {}).values()) + vested = sum( + float(v or 0) for v in (pool.get("vested_liquidity") or {}).values() + ) lock_pct = (locked + vested) / tvl * 100 if tvl else 0.0 - gates.append(("lp_lock", lock_pct >= config.min_lock_pct, f"{lock_pct:.1f}% locked/vested (min {config.min_lock_pct}%)")) + gates.append( + ( + "lp_lock", + lock_pct >= config.min_lock_pct, + f"{lock_pct:.1f}% locked/vested (min {config.min_lock_pct}%)", + ) + ) # 2. On-chain gates via RPC: mint authority + top-holder concentration. try: async with aiohttp.ClientSession() as session: if config.require_mint_renounced: - info = await _rpc(session, config.rpc_url, "getAccountInfo", - [base_mint, {"encoding": "jsonParsed"}]) - parsed = ((info or {}).get("value") or {}).get("data", {}).get("parsed", {}).get("info", {}) + info = await _rpc( + session, + config.rpc_url, + "getAccountInfo", + [base_mint, {"encoding": "jsonParsed"}], + ) + parsed = ( + ((info or {}).get("value") or {}) + .get("data", {}) + .get("parsed", {}) + .get("info", {}) + ) mint_auth = parsed.get("mintAuthority") - gates.append(("mint_renounced", mint_auth is None, f"mintAuthority={mint_auth or 'null (renounced)'}")) - - supply_res = await _rpc(session, config.rpc_url, "getTokenSupply", [base_mint]) + gates.append( + ( + "mint_renounced", + mint_auth is None, + f"mintAuthority={mint_auth or 'null (renounced)'}", + ) + ) + + supply_res = await _rpc( + session, config.rpc_url, "getTokenSupply", [base_mint] + ) total = float((supply_res or {}).get("value", {}).get("uiAmount") or 0) - largest = await _rpc(session, config.rpc_url, "getTokenLargestAccounts", [base_mint]) + largest = await _rpc( + session, config.rpc_url, "getTokenLargestAccounts", [base_mint] + ) accounts = (largest or {}).get("value", []) top10 = sum(float(a.get("uiAmount") or 0) for a in accounts[:10]) top10_pct = (top10 / total * 100) if total else 100.0 - gates.append(("holder_concentration", top10_pct <= config.max_top10_holder_pct, - f"top-10 hold {top10_pct:.1f}% (max {config.max_top10_holder_pct}%)")) + gates.append( + ( + "holder_concentration", + top10_pct <= config.max_top10_holder_pct, + f"top-10 hold {top10_pct:.1f}% (max {config.max_top10_holder_pct}%)", + ) + ) except Exception as e: gates.append(("rpc_checks", False, f"RPC checks failed: {e}")) passed_all = all(ok for _, ok, _ in gates) - lines = [f"Safety check for {base.get('symbol','?')} ({base_mint}) — pool {config.pool_address}", - f"VERDICT: {'PASS' if passed_all else 'FAIL'}", ""] + lines = [ + f"Safety check for {base.get('symbol','?')} ({base_mint}) — pool {config.pool_address}", + f"VERDICT: {'PASS' if passed_all else 'FAIL'}", + "", + ] for name, ok, detail in gates: lines.append(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}") lines.append("") - lines.append("NOTE: sellability (honeypot) is NOT checked here — round-trip a SELL and BUY quote via " - "manage_amm(quote_swap) before entering. See the launch_safety_check skill.") + lines.append( + "NOTE: sellability (honeypot) is NOT checked here — round-trip a SELL and BUY quote via " + "manage_amm(quote_swap) before entering. See the launch_safety_check skill." + ) return "\n".join(lines) diff --git a/agents/smart_money_flow/routines/onchain_flow.py b/agents/smart_money_flow/routines/onchain_flow.py index dd8b152b..99f7eb28 100644 --- a/agents/smart_money_flow/routines/onchain_flow.py +++ b/agents/smart_money_flow/routines/onchain_flow.py @@ -17,10 +17,13 @@ Execution venue is independent of this signal: pair the read with Derive perps (`derive_perpetual`) — or any perp connector — via the agent's trading context. """ + import asyncio import logging + from pydantic import BaseModel, Field from telegram.ext import ContextTypes + from config_manager import get_client logger = logging.getLogger(__name__) @@ -47,6 +50,7 @@ class Config(BaseModel): """Compute the Smart-Money Flow composite: cross-market + Solana on-chain pulse.""" + context_assets: str = Field( default="bitcoin,ethereum,solana", description="Comma-separated CoinGecko ids for regime + per-asset flow read", @@ -63,11 +67,14 @@ class Config(BaseModel): default="", description="OPTIONAL comma-separated XRPL addresses to watch (empty = skip)", ) - top_n_trending: int = Field(default=7, description="Trending coins to factor into momentum") + top_n_trending: int = Field( + default=7, description="Trending coins to factor into momentum" + ) # ── helpers ──────────────────────────────────────────────────────────────── + async def _get_json(url: str, timeout: float = 12) -> "dict | list | None": import httpx @@ -96,6 +103,7 @@ def _num(v) -> float: # ── raw fetchers (pure async, importable) ────────────────────────────────── + async def fetch_global() -> "dict | list | None": return await _get_json(f"{CG}/global") @@ -125,7 +133,9 @@ async def fetch_solana_pulse(top_n: int = 10) -> dict | None: read. Solana carries materially deeper on-chain liquidity than XRPL, so it is the DEFAULT on-chain signal source. """ - data = await _get_json(f"{GECKO}/networks/solana/tokens/{SOL_MINT}/pools?page=1&include=base_token,dex") + data = await _get_json( + f"{GECKO}/networks/solana/tokens/{SOL_MINT}/pools?page=1&include=base_token,dex" + ) if not data: return None pools = [] @@ -135,13 +145,15 @@ async def fetch_solana_pulse(top_n: int = 10) -> dict | None: tvl = _num(a.get("reserve_in_usd")) if tvl < 50_000: # skip dust/illiquid pools (meaningless momentum) continue - pools.append({ - "name": a.get("name", "?"), - "dex": a.get("dex_id", ""), - "vol24h": vol, - "chg24h": _num(a.get("price_change_percentage", {}).get("h24")), - "tvl": tvl, - }) + pools.append( + { + "name": a.get("name", "?"), + "dex": a.get("dex_id", ""), + "vol24h": vol, + "chg24h": _num(a.get("price_change_percentage", {}).get("h24")), + "tvl": tvl, + } + ) if not pools: return None total_vol = sum(p["vol24h"] for p in pools) @@ -165,8 +177,12 @@ async def fetch_xrpl_amm_pulse(issuer: str) -> dict | None: return None payload = { "method": "amm_info", - "params": [{"asset": {"currency": "XRP"}, - "asset2": {"currency": "RLUSD", "issuer": issuer}}], + "params": [ + { + "asset": {"currency": "XRP"}, + "asset2": {"currency": "RLUSD", "issuer": issuer}, + } + ], } import httpx @@ -185,18 +201,26 @@ async def fetch_xrpl_amm_pulse(issuer: str) -> dict | None: xrp = 0.0 return {"xrp_liquidity": xrp, "trading_fee_bps": amm.get("trading_fee")} except Exception as exc: - logger.warning("flow: xrpl amm pulse failed on %s: %s", node, type(exc).__name__) + logger.warning( + "flow: xrpl amm pulse failed on %s: %s", node, type(exc).__name__ + ) continue return None # ── synthesis (pure, importable) ─────────────────────────────────────────── + def synthesize(global_d, markets_d, trending_syms, solana_pulse, xrpl_amm) -> dict: """Turn raw fetches into a multi-asset flow composite. Returns a structured dict.""" # --- risk regime from /global --- - regime = {"label": "NEUTRAL", "mcap_change_24h": 0.0, "btc_dominance": 0.0, - "eth_dominance": 0.0, "score": 0.0} + regime = { + "label": "NEUTRAL", + "mcap_change_24h": 0.0, + "btc_dominance": 0.0, + "eth_dominance": 0.0, + "score": 0.0, + } if global_d: g = global_d.get("data", {}) mc = _num(g.get("market_cap_change_percentage_24h_usd")) @@ -208,12 +232,16 @@ def synthesize(global_d, markets_d, trending_syms, solana_pulse, xrpl_amm) -> di regime["eth_dominance"] = round(eth_d, 2) risk_score = _clamp(mc / 5.0) - _clamp((btc_d - 50.0) / 10.0) * 0.3 regime["score"] = round(_clamp(risk_score), 3) - regime["label"] = "RISK-ON" if regime["score"] > 0.15 else ("RISK-OFF" if regime["score"] < -0.15 else "NEUTRAL") + regime["label"] = ( + "RISK-ON" + if regime["score"] > 0.15 + else ("RISK-OFF" if regime["score"] < -0.15 else "NEUTRAL") + ) # --- per-asset flow intensity from /coins/markets --- trending_set = {s.upper() for s in trending_syms} assets_out = [] - for m in (markets_d or []): + for m in markets_d or []: sym = (m.get("symbol") or "").upper() mcap = _num(m.get("market_cap")) vol = _num(m.get("total_volume")) @@ -226,14 +254,16 @@ def synthesize(global_d, markets_d, trending_syms, solana_pulse, xrpl_amm) -> di flow = _clamp(vol_mcap * 5.0, -0.5, 0.5) * 0.4 + _clamp(chg / 6.0) * 0.6 if sym in trending_set: flow = _clamp(flow + 0.1) - assets_out.append({ - "symbol": sym, - "pair": f"{sym}-USDC", - "flow_score": round(flow, 3), - "volume_to_mcap": round(vol_mcap, 3), - "price_change_24h": round(chg, 2), - "trending": sym in trending_set, - }) + assets_out.append( + { + "symbol": sym, + "pair": f"{sym}-USDC", + "flow_score": round(flow, 3), + "volume_to_mcap": round(vol_mcap, 3), + "price_change_24h": round(chg, 2), + "trending": sym in trending_set, + } + ) assets_out.sort(key=lambda a: abs(a["flow_score"]), reverse=True) # --- on-chain pulse (Solana default; XRPL optional) --- @@ -276,25 +306,34 @@ def _format_verdict(sig: dict) -> str: f"- Verdict: **{sig['direction']}** ({sig['rationale']})", ] if best: - lines.append(f"- Best flow: **{best['symbol']}** score {best['flow_score']:+.2f} " - f"(vol/mcap {best['volume_to_mcap']}, 24h {best['price_change_24h']:+.2f}%, trend={best['trending']})") + lines.append( + f"- Best flow: **{best['symbol']}** score {best['flow_score']:+.2f} " + f"(vol/mcap {best['volume_to_mcap']}, 24h {best['price_change_24h']:+.2f}%, trend={best['trending']})" + ) sp = sig["onchain"].get("solana") if sp: - lines.append(f"- Solana on-chain pulse: flow {sp['flow_score']:+.2f}, " - f"top-pool vol24h ${sp['total_vol24h']:,.0f}, median chg {sp['median_chg24h']:+.1f}%") + lines.append( + f"- Solana on-chain pulse: flow {sp['flow_score']:+.2f}, " + f"top-pool vol24h ${sp['total_vol24h']:,.0f}, median chg {sp['median_chg24h']:+.1f}%" + ) for p in sp["pools"][:4]: - lines.append(f" - {p['name'][:24]:24} vol ${p['vol24h']:,.0f} chg {p['chg24h']:+.1f}%") + lines.append( + f" - {p['name'][:24]:24} vol ${p['vol24h']:,.0f} chg {p['chg24h']:+.1f}%" + ) if sig["onchain"].get("xrpl"): lines.append(f"- XRPL AMM pulse: {sig['onchain']['xrpl']}") lines.append("- Per-asset:") for a in sig["assets"][:5]: - lines.append(f" - {a['symbol']}: {a['flow_score']:+.2f} " - f"(vol/mcap {a['volume_to_mcap']}, 24h {a['price_change_24h']:+.2f}%, trend={a['trending']})") + lines.append( + f" - {a['symbol']}: {a['flow_score']:+.2f} " + f"(vol/mcap {a['volume_to_mcap']}, 24h {a['price_change_24h']:+.2f}%, trend={a['trending']})" + ) return "\n".join(lines) # ── orchestration ────────────────────────────────────────────────────────── + async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: """Entry point invoked by manage_routines. Fetches, scores, reports, returns.""" ids = [s.strip() for s in config.context_assets.split(",") if s.strip()] @@ -305,7 +344,11 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: fetch_markets(ids), fetch_solana_pulse(config.solana_top_n), ) - xrpl_amm = await fetch_xrpl_amm_pulse(config.xrpl_amm_issuer) if config.xrpl_amm_issuer else None + xrpl_amm = ( + await fetch_xrpl_amm_pulse(config.xrpl_amm_issuer) + if config.xrpl_amm_issuer + else None + ) sig = synthesize(global_d, markets, trending, solana, xrpl_amm) @@ -314,8 +357,13 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: from condor.reports import ReportBuilder builder = ReportBuilder("Smart-Money Flow") - builder.source("routine", "onchain_flow").tags(["flow", "on-chain", "solana", "smart-money"]) - builder.section("01 / RISK REGIME", "Cross-market risk-on/off from total mcap momentum and BTC dominance") + builder.source("routine", "onchain_flow").tags( + ["flow", "on-chain", "solana", "smart-money"] + ) + builder.section( + "01 / RISK REGIME", + "Cross-market risk-on/off from total mcap momentum and BTC dominance", + ) builder.kpi("Regime", sig["regime"]["label"]) builder.kpi("Mcap 24h", f"{sig['regime']['mcap_change_24h']:+.2f}%") builder.kpi("BTC Dominance", f"{sig['regime']['btc_dominance']:.1f}%") @@ -323,14 +371,29 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: builder.kpi("Solana Flow", f"{sig['solana_flow']:+.2f}") sp = sig["onchain"].get("solana") if sp: - builder.section("02 / SOLANA ON-CHAIN PULSE", "SOL top pools by 24h volume — crypto-native flow") + builder.section( + "02 / SOLANA ON-CHAIN PULSE", + "SOL top pools by 24h volume — crypto-native flow", + ) builder.kpi("Top-pool Vol 24h", f"${sp['total_vol24h']:,.0f}") builder.kpi("Median Chg 24h", f"{sp['median_chg24h']:+.1f}%") builder.kpi("On-chain Flow", f"{sp['flow_score']:+.2f}") builder.table(sp["pools"][:8], ["name", "dex", "vol24h", "chg24h", "tvl"]) if sig["assets"]: - builder.section("03 / CROSS-MARKET CONTEXT", "Market-wide flow intensity (BTC/ETH/SOL)") - builder.table(sig["assets"][:6], ["symbol", "pair", "flow_score", "volume_to_mcap", "price_change_24h", "trending"]) + builder.section( + "03 / CROSS-MARKET CONTEXT", "Market-wide flow intensity (BTC/ETH/SOL)" + ) + builder.table( + sig["assets"][:6], + [ + "symbol", + "pair", + "flow_score", + "volume_to_mcap", + "price_change_24h", + "trending", + ], + ) builder.markdown(_format_verdict(sig)) builder.manual_order() await builder.save() diff --git a/agents/solana_dex_lp_expert/routines/lp_scanner.py b/agents/solana_dex_lp_expert/routines/lp_scanner.py index f374459f..2a8ff926 100644 --- a/agents/solana_dex_lp_expert/routines/lp_scanner.py +++ b/agents/solana_dex_lp_expert/routines/lp_scanner.py @@ -16,12 +16,14 @@ hold — the agent should never open a 2nd slot on the same pool or token. """ -import logging import asyncio +import logging import re + import aiohttp from pydantic import BaseModel, Field, field_validator from telegram.ext import ContextTypes + from config_manager import get_client from handlers.dex.geckoterminal import _extract_pool_data diff --git a/agents/xrpl_market_maker/routines/xrpl_mm_quote_planner.py b/agents/xrpl_market_maker/routines/xrpl_mm_quote_planner.py index 8a5d7643..ce497905 100644 --- a/agents/xrpl_market_maker/routines/xrpl_mm_quote_planner.py +++ b/agents/xrpl_market_maker/routines/xrpl_mm_quote_planner.py @@ -1,4 +1,5 @@ """Plan XRPL CLOB maker quotes: reference fair value, spread bounds, viability verdict.""" + import asyncio import logging import math @@ -27,10 +28,30 @@ # 733 days of Bitget perp 1H bars. Values are vol relative to the daily average. # Trough 04:00-11:00 UTC (Asia afternoon / pre-Europe); peak 13:00-15:00 UTC (US open). HOUR_VOL_MULT = { - 0: 1.03, 1: 1.10, 2: 0.95, 3: 0.89, 4: 0.82, 5: 0.85, - 6: 0.82, 7: 0.82, 8: 0.91, 9: 0.83, 10: 0.78, 11: 0.82, - 12: 0.92, 13: 1.28, 14: 1.50, 15: 1.40, 16: 1.16, 17: 1.21, - 18: 1.07, 19: 1.04, 20: 1.02, 21: 0.97, 22: 0.97, 23: 0.82, + 0: 1.03, + 1: 1.10, + 2: 0.95, + 3: 0.89, + 4: 0.82, + 5: 0.85, + 6: 0.82, + 7: 0.82, + 8: 0.91, + 9: 0.83, + 10: 0.78, + 11: 0.82, + 12: 0.92, + 13: 1.28, + 14: 1.50, + 15: 1.40, + 16: 1.16, + 17: 1.21, + 18: 1.07, + 19: 1.04, + 20: 1.02, + 21: 0.97, + 22: 0.97, + 23: 0.82, } MS_PER_HOUR = 3_600_000 @@ -38,11 +59,15 @@ class Config(BaseModel): """Plan maker quotes for an XRPL CLOB pair against a CEX reference price.""" - xrpl_pair: str = Field(default="RLUSD-XRP", description="Pair as the xrpl connector names it") + xrpl_pair: str = Field( + default="RLUSD-XRP", description="Pair as the xrpl connector names it" + ) reference_connector: str = Field( default="bitget_perpetual", description="CEX connector supplying fair value" ) - reference_pair: str = Field(default="XRP-USDT", description="Reference pair for XRP/USD") + reference_pair: str = Field( + default="XRP-USDT", description="Reference pair for XRP/USD" + ) tick_interval_sec: int = Field(default=300, description="Seconds between LLM ticks") requote_interval_sec: int = Field( default=0, @@ -51,9 +76,12 @@ class Config(BaseModel): "tick — the bot requotes without the agent, so the LLM tick must not set the floor", ) levels_per_side: int = Field(default=3, description="Quote levels per side") - total_amount_quote: float = Field(default=100.0, description="Capital to deploy, USD") + total_amount_quote: float = Field( + default=100.0, description="Capital to deploy, USD" + ) adverse_k: float = Field( - default=1.0, description="Multiplier on expected adverse move; higher = wider floor" + default=1.0, + description="Multiplier on expected adverse move; higher = wider floor", ) use_vol_clock: bool = Field( default=True, @@ -63,9 +91,12 @@ class Config(BaseModel): default=0.1, description="Assumed AMM fee %% if amm_info is unreachable" ) amm_asset2_issuer: str = Field( - default="", description="Issuer address of the non-XRP asset (blank = skip amm_info)" + default="", + description="Issuer address of the non-XRP asset (blank = skip amm_info)", + ) + amm_asset2_currency: str = Field( + default="RLUSD", description="Non-XRP asset currency code" ) - amm_asset2_currency: str = Field(default="RLUSD", description="Non-XRP asset currency code") # ── helpers ────────────────────────────────────────────────────────────────── @@ -115,7 +146,12 @@ async def _fetch_amm_fee_pct(currency: str, issuer: str) -> tuple[float | None, return None, "no issuer configured — amm_info skipped" payload = { "method": "amm_info", - "params": [{"asset": {"currency": "XRP"}, "asset2": {"currency": currency, "issuer": issuer}}], + "params": [ + { + "asset": {"currency": "XRP"}, + "asset2": {"currency": currency, "issuer": issuer}, + } + ], } try: async with httpx.AsyncClient(timeout=10) as http: @@ -180,9 +216,14 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: try: candles_raw, ref_prices = await asyncio.gather( client.market_data.get_candles( - config.reference_connector, config.reference_pair, interval="1m", max_records=120 + config.reference_connector, + config.reference_pair, + interval="1m", + max_records=120, + ), + client.market_data.get_prices( + config.reference_connector, [config.reference_pair] ), - client.market_data.get_prices(config.reference_connector, [config.reference_pair]), ) except Exception as exc: return f"reference_price: ERROR fetching {config.reference_pair} — {exc}" @@ -198,7 +239,9 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: implied_xrpl_price = (1.0 / ref_mid) if ref_mid > 0 else 0.0 out.append("=== REFERENCE (fair value source) ===") - out.append(f"reference_pair: {config.reference_pair} @ {config.reference_connector}") + out.append( + f"reference_pair: {config.reference_pair} @ {config.reference_connector}" + ) out.append(f"reference_mid_usd: {ref_mid:.6f}") out.append(f"implied_{config.xrpl_pair}: {implied_xrpl_price:.6f} (1 / XRP-USD)") @@ -251,7 +294,9 @@ def _utc(ms: int) -> str: f"(UTC {_utc(now_ms)}-{_utc(fwd_end_ms)}, quote live window)" ) out.append(f"vol_adjusted: {vol_adj * math.sqrt(60) * 100:.4f}% per minute") - out.append(f"tick_interval_sec: {config.tick_interval_sec} (LLM reasoning cadence)") + out.append( + f"tick_interval_sec: {config.tick_interval_sec} (LLM reasoning cadence)" + ) out.append( f"requote_interval_sec: {requote_sec} (quote exposure — THIS sets the floor)" ) @@ -281,7 +326,9 @@ def _utc(ms: int) -> str: out.append("") out.append("=== SPREAD CEILING (AMM fee to undercut) ===") out.append(f"amm_trading_fee_pct: {amm_fee_pct:.4f}% ({fee_note})") - out.append(f"spread_ceiling_bps: {ceiling_bps:.2f} (quote wider and flow routes to the AMM)") + out.append( + f"spread_ceiling_bps: {ceiling_bps:.2f} (quote wider and flow routes to the AMM)" + ) # 4. Viability — the load-bearing verdict. viable = floor_bps < ceiling_bps @@ -345,7 +392,9 @@ def _utc(ms: int) -> str: # A controller's total_amount_quote is denominated in the pair's QUOTE asset. On # RLUSD-XRP that is XRP, not USD — passing a USD figure straight through oversizes # the deployment by the XRP price (~3x), silently breaching the risk limit. - quote_asset = config.xrpl_pair.split("-")[-1].upper() if "-" in config.xrpl_pair else "" + quote_asset = ( + config.xrpl_pair.split("-")[-1].upper() if "-" in config.xrpl_pair else "" + ) if quote_asset == "XRP" and ref_mid > 0: amount_in_quote = config.total_amount_quote / ref_mid out.append( @@ -375,8 +424,12 @@ def _utc(ms: int) -> str: bids = (book or {}).get("bids") or [] asks = (book or {}).get("asks") or [] if bids and asks: - best_bid = float(bids[0][0] if isinstance(bids[0], (list, tuple)) else bids[0]["price"]) - best_ask = float(asks[0][0] if isinstance(asks[0], (list, tuple)) else asks[0]["price"]) + best_bid = float( + bids[0][0] if isinstance(bids[0], (list, tuple)) else bids[0]["price"] + ) + best_ask = float( + asks[0][0] if isinstance(asks[0], (list, tuple)) else asks[0]["price"] + ) mid = (best_bid + best_ask) / 2 out.append(f"best_bid: {best_bid:.6f}") out.append(f"best_ask: {best_ask:.6f}") @@ -391,6 +444,8 @@ def _utc(ms: int) -> str: out.append("status: EMPTY OR UNAVAILABLE — do not quote blind") except Exception as exc: out.append(f"status: ERROR — {exc}") - out.append("action: treat as a hard stop; do not place offers without live book state") + out.append( + "action: treat as a hard stop; do not place offers without live book state" + ) return "\n".join(out) diff --git a/condor/acp/__init__.py b/condor/acp/__init__.py index 65c3804a..ffe6962d 100644 --- a/condor/acp/__init__.py +++ b/condor/acp/__init__.py @@ -1,14 +1,14 @@ from .client import ( - ACPClient, ACP_COMMANDS, - resolve_acp, + ACPClient, + ACPEvent, + Heartbeat, PermissionCallback, + PromptDone, TextChunk, ThoughtChunk, ToolCallEvent, ToolCallUpdate, - PromptDone, - Heartbeat, - ACPEvent, + resolve_acp, ) from .pydantic_ai_client import PydanticAIClient, is_pydantic_ai_model diff --git a/condor/acp/jsonrpc.py b/condor/acp/jsonrpc.py index 5e9e0e5a..c1563553 100644 --- a/condor/acp/jsonrpc.py +++ b/condor/acp/jsonrpc.py @@ -23,7 +23,9 @@ def __init__(self, code: int, message: str, data: Any = None): detail = str(data.get("details") or data.get("message") or "") or str(data) elif data: detail = str(data) - super().__init__(f"[{code}] {message}" + (f": {detail[:300]}" if detail else "")) + super().__init__( + f"[{code}] {message}" + (f": {detail[:300]}" if detail else "") + ) # Standard JSON-RPC 2.0 error codes @@ -91,7 +93,9 @@ async def handle_line(self, line: str, writer: asyncio.StreamWriter) -> None: if "error" in data: err = data["error"] future.set_exception( - JSONRPCError(err.get("code", -1), err.get("message", ""), err.get("data")) + JSONRPCError( + err.get("code", -1), err.get("message", ""), err.get("data") + ) ) else: future.set_result(data.get("result")) @@ -108,7 +112,10 @@ async def handle_line(self, line: str, writer: asyncio.StreamWriter) -> None: if msg_id is not None: resp = { "jsonrpc": "2.0", - "error": {"code": METHOD_NOT_FOUND, "message": f"Method not found: {method}"}, + "error": { + "code": METHOD_NOT_FOUND, + "message": f"Method not found: {method}", + }, "id": msg_id, } writer.write((json.dumps(resp) + "\n").encode()) @@ -116,7 +123,11 @@ async def handle_line(self, line: str, writer: asyncio.StreamWriter) -> None: return try: - result = handler(**params) if not asyncio.iscoroutinefunction(handler) else await handler(**params) + result = ( + handler(**params) + if not asyncio.iscoroutinefunction(handler) + else await handler(**params) + ) except Exception as e: log.exception("Handler error for %s", method) if msg_id is not None: diff --git a/condor/backtest_store.py b/condor/backtest_store.py index 77174a03..8aa5cc65 100644 --- a/condor/backtest_store.py +++ b/condor/backtest_store.py @@ -3,6 +3,7 @@ Each backtest is stored as an individual JSON file under data/backtests/. A lightweight index (_index.json) tracks task_id -> server mapping for fast listing. """ + from __future__ import annotations import json @@ -23,7 +24,9 @@ class BacktestStore: def __init__(self, data_dir: Path = _DATA_DIR) -> None: self._dir = data_dir self._index_path = data_dir / "_index.json" - self._index: dict[str, dict[str, str]] = {} # task_id -> {server, ...light meta} + self._index: dict[str, dict[str, str]] = ( + {} + ) # task_id -> {server, ...light meta} self._dir.mkdir(parents=True, exist_ok=True) self._load_index() self._migrate_legacy() @@ -111,7 +114,11 @@ def _rebuild_index(self) -> None: task_id = path.stem self._index[task_id] = { "server": data.get("server", ""), - "config": data.get("config", {}).get("id", "") if isinstance(data.get("config"), dict) else "", + "config": ( + data.get("config", {}).get("id", "") + if isinstance(data.get("config"), dict) + else "" + ), } except Exception: logger.warning("Skipping corrupt backtest file %s", path) @@ -126,13 +133,19 @@ def _migrate_legacy(self) -> None: if not isinstance(legacy_data, dict) or not legacy_data: _LEGACY_FILE.unlink() return - logger.info("Migrating %d backtest results from legacy store", len(legacy_data)) + logger.info( + "Migrating %d backtest results from legacy store", len(legacy_data) + ) for task_id, entry in legacy_data.items(): server = entry.pop("server", "") self._write_file(task_id, {"server": server, **entry}) self._index[task_id] = { "server": server, - "config": entry.get("config", {}).get("id", "") if isinstance(entry.get("config"), dict) else "", + "config": ( + entry.get("config", {}).get("id", "") + if isinstance(entry.get("config"), dict) + else "" + ), } self._persist_index() # Remove legacy file after successful migration diff --git a/condor/cache.py b/condor/cache.py index ce1262e6..783a0cfa 100644 --- a/condor/cache.py +++ b/condor/cache.py @@ -125,9 +125,7 @@ def evict_expired( del cache[k] if stale_keys: - logger.debug( - "Evicted %d stale entries from '%s'", len(stale_keys), namespace - ) + logger.debug("Evicted %d stale entries from '%s'", len(stale_keys), namespace) return len(stale_keys) diff --git a/condor/fetchers/__init__.py b/condor/fetchers/__init__.py index defd2589..ad80054a 100644 --- a/condor/fetchers/__init__.py +++ b/condor/fetchers/__init__.py @@ -34,39 +34,39 @@ façade consumer; they are not the package's public surface. """ -from condor.fetchers.portfolio import fetch_portfolio -from condor.fetchers.positions import fetch_positions -from condor.fetchers.orders import fetch_active_orders -from condor.fetchers.trading_rules import fetch_trading_rules +from condor.fetchers.bots import fetch_bot_runs, fetch_bots_status from condor.fetchers.connectors import ( - fetch_connectors, fetch_available_cex_connectors, + fetch_connectors, fetch_venues, is_cex_connector, ) from condor.fetchers.executors import ( - fetch_executors, - fetch_all_executors, create_executor, - stop_executor, describe_executor_error, + extract_executors_list, + fetch_all_executors, + fetch_executors, get_executor_detail, - get_executor_type, + get_executor_fees, get_executor_pnl, + get_executor_type, get_executor_volume, - get_executor_fees, - extract_executors_list, + stop_executor, ) -from condor.fetchers.bots import fetch_bots_status, fetch_bot_runs from condor.fetchers.market_data import ( - fetch_current_price, - fetch_candles, fetch_candle_connectors, + fetch_candles, + fetch_current_price, fetch_rates, fetch_ticker_pool, fetch_tickers, ) +from condor.fetchers.orders import fetch_active_orders +from condor.fetchers.portfolio import fetch_portfolio +from condor.fetchers.positions import fetch_positions from condor.fetchers.server_status import fetch_server_status +from condor.fetchers.trading_rules import fetch_trading_rules __all__ = [ "fetch_portfolio", diff --git a/condor/fetchers/connectors.py b/condor/fetchers/connectors.py index d15db9f5..bbba72af 100644 --- a/condor/fetchers/connectors.py +++ b/condor/fetchers/connectors.py @@ -8,7 +8,13 @@ logger = logging.getLogger(__name__) _DEX_PREFIXES = ( - "solana", "ethereum", "polygon", "arbitrum", "base", "optimism", "avalanche", + "solana", + "ethereum", + "polygon", + "arbitrum", + "base", + "optimism", + "avalanche", ) diff --git a/condor/fetchers/executors.py b/condor/fetchers/executors.py index 3142ea35..e47422a1 100644 --- a/condor/fetchers/executors.py +++ b/condor/fetchers/executors.py @@ -47,7 +47,12 @@ def get_executor_type(executor: Dict[str, Any]) -> str: for source in (config, executor): ex_type = source.get("type", "") or source.get("executor_type", "") if isinstance(ex_type, str) and ex_type: - label = ex_type.lower().replace("_executor", "").replace("executor", "").strip("_") + label = ( + ex_type.lower() + .replace("_executor", "") + .replace("executor", "") + .strip("_") + ) if label: return label if "start_price" in config and "end_price" in config: @@ -86,8 +91,13 @@ def normalize_executor_side(raw: Any) -> str: def get_executor_pnl(executor: Dict[str, Any]) -> float: """Extract PnL from an executor response.""" for key in ( - "net_pnl_quote", "pnl_quote", "unrealized_pnl_quote", - "realized_pnl_quote", "net_pnl", "pnl", "close_pnl", + "net_pnl_quote", + "pnl_quote", + "unrealized_pnl_quote", + "realized_pnl_quote", + "net_pnl", + "pnl", + "close_pnl", ): val = executor.get(key) if val is not None and val != 0: diff --git a/condor/fetchers/trading_rules.py b/condor/fetchers/trading_rules.py index 809bdb9d..c10b26af 100644 --- a/condor/fetchers/trading_rules.py +++ b/condor/fetchers/trading_rules.py @@ -43,11 +43,14 @@ async def fetch_trading_rules( if "404" in error_str or "401" in error_str or "not found" in error_str.lower(): logger.debug( "Connector '%s' not available for trading rules: %s", - connector_name, e, + connector_name, + e, ) else: logger.error( "Error fetching trading rules for %s: %s", - connector_name, e, exc_info=True, + connector_name, + e, + exc_info=True, ) return {} diff --git a/condor/persistence.py b/condor/persistence.py index bf023c21..f7db4e1e 100644 --- a/condor/persistence.py +++ b/condor/persistence.py @@ -124,9 +124,7 @@ def _load_singlefile(self) -> None: self.conversations = data.get("conversations", {}) self.user_data = data.get("user_data", {}) self.chat_data = data.get("chat_data", {}) - self.bot_data = data.get( - "bot_data", self.context_types.bot_data() - ) + self.bot_data = data.get("bot_data", self.context_types.bot_data()) self.callback_data = data.get("callback_data", None) else: # Both files missing or corrupt – start fresh diff --git a/condor/preferences.py b/condor/preferences.py index c9c2acbe..73ac5b9c 100644 --- a/condor/preferences.py +++ b/condor/preferences.py @@ -1220,7 +1220,8 @@ def remove_custom_provider(user_data: Dict, name: str) -> bool: def unique_provider_name(user_data: Dict, suggested: str) -> str: """Return ``suggested`` (sanitized), suffixed with -2, -3, ... if taken.""" existing = { - sanitize_provider_name(p.get("name", "")) for p in get_custom_providers(user_data) + sanitize_provider_name(p.get("name", "")) + for p in get_custom_providers(user_data) } base = sanitize_provider_name(suggested) if base not in existing: diff --git a/condor/web/routes/archived.py b/condor/web/routes/archived.py index d1df8566..5264a59f 100644 --- a/condor/web/routes/archived.py +++ b/condor/web/routes/archived.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query -from config_manager import get_config_manager from condor.fetchers.executors import normalize_executor_side from condor.web.auth import get_current_user from condor.web.models import ( @@ -18,6 +17,7 @@ PnlPoint, WebUser, ) +from config_manager import get_config_manager logger = logging.getLogger(__name__) @@ -66,6 +66,7 @@ def _parse_json_field(val: Any) -> dict: return val if isinstance(val, str) and val.strip().startswith("{"): import json + try: return json.loads(val) except Exception: @@ -105,10 +106,7 @@ def _normalize_executors(raw: list[dict]) -> list[NormalizedExecutor]: ) # Resolve trading pair - trading_pair = str( - ex.get("trading_pair", "") - or config.get("trading_pair", "") - ) + trading_pair = str(ex.get("trading_pair", "") or config.get("trading_pair", "")) # Resolve prices entry_price = float( @@ -129,26 +127,32 @@ def _normalize_executors(raw: list[dict]) -> list[NormalizedExecutor]: volume = float(ex.get("filled_amount_quote", 0) or ex.get("volume", 0) or 0) net_pnl_pct = float(ex.get("net_pnl_pct", 0) or 0) - result.append(NormalizedExecutor( - id=str(ex.get("id", "") or ex.get("executor_id", "")), - type=str(ex.get("type", "") or ex.get("executor_type", "") or config.get("type", "position")), - connector=connector, - trading_pair=trading_pair, - side=normalize_executor_side(side_raw), - status=str(ex.get("status", "") or "closed"), - close_type=str(ex.get("close_type", "") or ""), - pnl=pnl, - volume=volume, - timestamp=_to_epoch_seconds(ex.get("timestamp")), - close_timestamp=_to_epoch_seconds(ex.get("close_timestamp")), - entry_price=entry_price, - current_price=close_price, - cum_fees_quote=fees, - net_pnl_pct=net_pnl_pct, - controller_id=str(ex.get("controller_id", "")), - custom_info=custom_info, - config=config, - )) + result.append( + NormalizedExecutor( + id=str(ex.get("id", "") or ex.get("executor_id", "")), + type=str( + ex.get("type", "") + or ex.get("executor_type", "") + or config.get("type", "position") + ), + connector=connector, + trading_pair=trading_pair, + side=normalize_executor_side(side_raw), + status=str(ex.get("status", "") or "closed"), + close_type=str(ex.get("close_type", "") or ""), + pnl=pnl, + volume=volume, + timestamp=_to_epoch_seconds(ex.get("timestamp")), + close_timestamp=_to_epoch_seconds(ex.get("close_timestamp")), + entry_price=entry_price, + current_price=close_price, + cum_fees_quote=fees, + net_pnl_pct=net_pnl_pct, + controller_id=str(ex.get("controller_id", "")), + custom_info=custom_info, + config=config, + ) + ) return result @@ -237,7 +241,9 @@ async def _fetch_and_cache_performance( _executors_cache[cache_key] = executors # Derive primary connector and trading pair - primary_connector, primary_trading_pair = _derive_primary_pair(executors, exchanges, trading_pairs) + primary_connector, primary_trading_pair = _derive_primary_pair( + executors, exchanges, trading_pairs + ) # Calculate PnL from trades from condor.archived_pnl import calculate_pnl_from_trades @@ -342,11 +348,15 @@ async def list_archived_bots(name: str, user: WebUser = Depends(get_current_user return {"bots": bots} -@router.get("/servers/{name}/archived/performance", response_model=ArchivedBotPerformance) +@router.get( + "/servers/{name}/archived/performance", response_model=ArchivedBotPerformance +) async def get_archived_performance( name: str, db_path: str = Query(..., description="Database path"), - include_executors: bool = Query(False, description="Include full executor list in response"), + include_executors: bool = Query( + False, description="Include full executor list in response" + ), user: WebUser = Depends(get_current_user), ): cm = get_config_manager() diff --git a/condor/web/routes/executors.py b/condor/web/routes/executors.py index 9038f461..b81d9d34 100644 --- a/condor/web/routes/executors.py +++ b/condor/web/routes/executors.py @@ -8,7 +8,13 @@ logger = logging.getLogger(__name__) -from config_manager import get_config_manager +from condor.fetchers.executors import ( + EXECUTORS_POLL_MAX, + MAX_EXECUTORS_FETCH, + describe_executor_error, +) +from condor.fetchers.executors import extract_executors_list as _extract_executors_list +from condor.fetchers.executors import fetch_all_executors, summarize_executors_by_quote from condor.web.auth import get_current_user from condor.web.models import ( CreateExecutorRequest, @@ -16,14 +22,7 @@ ExecutorPeriodSummary, WebUser, ) -from condor.fetchers.executors import ( - describe_executor_error, - fetch_all_executors, - extract_executors_list as _extract_executors_list, - summarize_executors_by_quote, - EXECUTORS_POLL_MAX, - MAX_EXECUTORS_FETCH, -) +from config_manager import get_config_manager router = APIRouter(tags=["executors"]) @@ -71,7 +70,12 @@ async def list_executors( trading_pair: str = Query(default="", description="Filter by trading pair"), status: str = Query(default="", description="Filter by status"), controller_id: str = Query(default="", description="Filter by controller id"), - limit: int = Query(default=0, ge=0, le=MAX_EXECUTORS_FETCH, description="Max executors to return (0 = default SDS cache)"), + limit: int = Query( + default=0, + ge=0, + le=MAX_EXECUTORS_FETCH, + description="Max executors to return (0 = default SDS cache)", + ), user: WebUser = Depends(get_current_user), ): cm = get_config_manager() @@ -105,7 +109,9 @@ async def list_executors( raise _executor_error("Failed to fetch executors", e) else: try: - result = await get_server_data_service().get_or_fetch(name, ServerDataType.EXECUTORS) + result = await get_server_data_service().get_or_fetch( + name, ServerDataType.EXECUTORS + ) except Exception as e: logger.exception("Failed to fetch executors for server %s", name) raise _executor_error("Failed to fetch executors", e) @@ -399,7 +405,10 @@ async def get_positions_held( else: positions = [] - return {"positions": positions, "summary": result if isinstance(result, dict) else {}} + return { + "positions": positions, + "summary": result if isinstance(result, dict) else {}, + } @router.delete("/servers/{name}/executors/positions/{connector}/{pair}") diff --git a/condor/web/routes/positions.py b/condor/web/routes/positions.py index fa4abb96..c3845044 100644 --- a/condor/web/routes/positions.py +++ b/condor/web/routes/positions.py @@ -5,9 +5,9 @@ from fastapi import APIRouter, Depends, HTTPException -from config_manager import get_config_manager from condor.web.auth import get_current_user from condor.web.models import WebUser +from config_manager import get_config_manager logger = logging.getLogger(__name__) @@ -30,7 +30,9 @@ def _normalize_position(pos: dict, source: str, source_name: str) -> dict: "entry_price": entry_price, "notional_value": notional_value, "current_price": pos.get("current_price") or 0, - "unrealized_pnl": pos.get("unrealized_pnl_quote") or pos.get("unrealized_pnl") or 0, + "unrealized_pnl": pos.get("unrealized_pnl_quote") + or pos.get("unrealized_pnl") + or 0, "leverage": pos.get("leverage") or 1, "controller_id": pos.get("controller_id") or "", "realized_pnl": pos.get("realized_pnl_quote") or 0, @@ -72,7 +74,9 @@ async def fetch_executor_positions(): async def fetch_bot_positions(): try: - result = await get_server_data_service().get_or_fetch(name, ServerDataType.BOTS_STATUS) + result = await get_server_data_service().get_or_fetch( + name, ServerDataType.BOTS_STATUS + ) if result is None: return [] @@ -81,7 +85,11 @@ async def fetch_bot_positions(): if isinstance(result, dict): data = result.get("data", {}) if isinstance(data, dict): - bots_list = [{"bot_name": k, **v} for k, v in data.items() if isinstance(v, dict)] + bots_list = [ + {"bot_name": k, **v} + for k, v in data.items() + if isinstance(v, dict) + ] elif isinstance(data, list): bots_list = [b for b in data if isinstance(b, dict)] elif isinstance(result, list): @@ -121,8 +129,7 @@ async def fetch_bot_positions(): ] bot_positions = [ - _normalize_position(pos, "bot", source_name) - for pos, source_name in bot_raw + _normalize_position(pos, "bot", source_name) for pos, source_name in bot_raw ] # Enrich positions missing current_price (the positions_summary endpoint doesn't provide it) diff --git a/condor/web/routes/reports.py b/condor/web/routes/reports.py index ae62b2ab..af3ed597 100644 --- a/condor/web/routes/reports.py +++ b/condor/web/routes/reports.py @@ -13,7 +13,7 @@ list_reports_grouped, ) from condor.web.auth import get_current_user -from condor.web.models import ReportSummary, ReportsListResponse, WebUser +from condor.web.models import ReportsListResponse, ReportSummary, WebUser router = APIRouter(prefix="/reports", tags=["reports"]) diff --git a/condor/web/routes/settings.py b/condor/web/routes/settings.py index 63b0cdf2..b7408a5f 100644 --- a/condor/web/routes/settings.py +++ b/condor/web/routes/settings.py @@ -5,7 +5,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query -from config_manager import ServerPermission, get_config_manager from condor.web.auth import get_current_user from condor.web.models import ( AddCredentialRequest, @@ -17,6 +16,7 @@ UpdateServerRequest, WebUser, ) +from config_manager import ServerPermission, get_config_manager logger = logging.getLogger(__name__) @@ -48,6 +48,7 @@ async def list_settings_servers(user: WebUser = Depends(get_current_user)): accessible = cm.list_accessible_servers(user.id) from condor.server_data_service import ServerDataType, get_server_data_service + sds = get_server_data_service() # Fetch status for all servers concurrently (uses SDS cache, instant if warm) @@ -61,13 +62,15 @@ async def _get_status(name: str) -> dict: for (name, cfg), status in zip(accessible.items(), statuses): perm = cm.get_server_permission(user.id, name) online = status.get("status") == "online" - results.append(ServerInfo( - name=name, - host=cfg.get("host", ""), - port=cfg.get("port", 0), - online=online, - permission=perm.value if perm else "trader", - )) + results.append( + ServerInfo( + name=name, + host=cfg.get("host", ""), + port=cfg.get("port", 0), + online=online, + permission=perm.value if perm else "trader", + ) + ) return sorted(results, key=lambda s: (not s.online, s.name)) @@ -206,10 +209,12 @@ async def gateway_start( raise HTTPException(status_code=403, detail="No access") client = await _get_client(cm, server) try: - result = await client.gateway.start({ - "image": req.image, - "port": req.port, - }) + result = await client.gateway.start( + { + "image": req.image, + "port": req.port, + } + ) return {"started": True, "result": result} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -271,12 +276,16 @@ async def get_voice_settings(user: WebUser = Depends(get_current_user)): """Get voice/transcription preferences for the current user.""" cm = get_config_manager() prefs = cm.get_user_preferences(user.id) - voice = prefs.get("voice", { - "whisper_model": "small", - "language": None, - "auto_send": True, - }) - from condor.preferences import WHISPER_MODELS, VOICE_LANGUAGES + voice = prefs.get( + "voice", + { + "whisper_model": "small", + "language": None, + "auto_send": True, + }, + ) + from condor.preferences import VOICE_LANGUAGES, WHISPER_MODELS + return { "voice": voice, "available_models": WHISPER_MODELS, @@ -292,11 +301,14 @@ async def update_voice_settings( """Update voice/transcription preferences.""" cm = get_config_manager() prefs = cm.get_user_preferences(user.id) - voice = prefs.get("voice", { - "whisper_model": "small", - "language": None, - "auto_send": True, - }) + voice = prefs.get( + "voice", + { + "whisper_model": "small", + "language": None, + "auto_send": True, + }, + ) allowed_keys = {"whisper_model", "language", "auto_send"} for key in allowed_keys: if key in body: @@ -304,6 +316,7 @@ async def update_voice_settings( # Validate whisper_model from condor.preferences import WHISPER_MODELS + if voice.get("whisper_model") not in WHISPER_MODELS: voice["whisper_model"] = "base" @@ -336,10 +349,16 @@ async def list_credentials( if isinstance(item, str): credentials.append({"connector_name": item, "connector_type": ""}) elif isinstance(item, dict): - credentials.append({ - "connector_name": item.get("connector_name", item.get("name", "")), - "connector_type": item.get("connector_type", item.get("type", "")), - }) + credentials.append( + { + "connector_name": item.get( + "connector_name", item.get("name", "") + ), + "connector_type": item.get( + "connector_type", item.get("type", "") + ), + } + ) return {"credentials": credentials} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -360,16 +379,28 @@ async def list_connectors( sds = get_server_data_service() raw = await sds.get_or_fetch(server, ServerDataType.ALL_CONNECTORS) if raw is None: - raise HTTPException(status_code=502, detail="Cannot fetch connectors from server") + raise HTTPException( + status_code=502, detail="Cannot fetch connectors from server" + ) # API returns plain strings — filter out testnet/gateway connectors - names = [c for c in raw if isinstance(c, str) and "testnet" not in c.lower() and "sandbox" not in c.lower() and "/" not in c] + names = [ + c + for c in raw + if isinstance(c, str) + and "testnet" not in c.lower() + and "sandbox" not in c.lower() + and "/" not in c + ] if type: if type.lower() == "perpetual": names = [c for c in names if "perpetual" in c.lower()] else: names = [c for c in names if "perpetual" not in c.lower()] - connectors = [{"name": c, "type": "perpetual" if "perpetual" in c.lower() else "spot"} for c in names] + connectors = [ + {"name": c, "type": "perpetual" if "perpetual" in c.lower() else "spot"} + for c in names + ] return {"connectors": connectors} @@ -408,6 +439,7 @@ async def add_credential( ) # Invalidate configured connectors cache from condor.server_data_service import ServerDataType, get_server_data_service + get_server_data_service().invalidate(server, ServerDataType.CONNECTORS) return {"added": True, "result": result} except Exception as e: @@ -431,6 +463,7 @@ async def delete_credential( ) # Invalidate configured connectors + portfolio caches so the removed key disappears immediately from condor.server_data_service import ServerDataType, get_server_data_service + sds = get_server_data_service() sds.invalidate(server, ServerDataType.CONNECTORS) sds.invalidate(server, ServerDataType.PORTFOLIO) diff --git a/condor/web/routes/transcribe.py b/condor/web/routes/transcribe.py index 170898be..fb534719 100644 --- a/condor/web/routes/transcribe.py +++ b/condor/web/routes/transcribe.py @@ -2,7 +2,7 @@ import logging -from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from condor.web.auth import get_current_user from condor.web.models import WebUser @@ -39,6 +39,7 @@ async def transcribe_audio( if effective_lang is None or effective_model is None: try: from config_manager import get_config_manager + cm = get_config_manager() voice_prefs = cm.get_user_preferences(user.id).get("voice", {}) if effective_lang is None: diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py index dacee375..c70e57ca 100644 --- a/handlers/admin/__init__.py +++ b/handlers/admin/__init__.py @@ -9,11 +9,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes -from config_manager import ( - ServerPermission, - UserRole, - get_config_manager, -) +from config_manager import ServerPermission, UserRole, get_config_manager from utils.auth import admin_required from utils.telegram_formatters import escape_markdown_v2 diff --git a/handlers/admin/update.py b/handlers/admin/update.py index 679c5885..5668c8d6 100644 --- a/handlers/admin/update.py +++ b/handlers/admin/update.py @@ -52,8 +52,7 @@ async def _check_and_show(message_or_query, context: ContextTypes.DEFAULT_TYPE) if condor_info["error"]: sections.append( - f"*Condor*\n" - f"Error: `{escape_markdown_v2(condor_info['error'])}`" + f"*Condor*\n" f"Error: `{escape_markdown_v2(condor_info['error'])}`" ) else: local = escape_markdown_v2(condor_info["local_commit"]) @@ -114,7 +113,9 @@ async def _check_and_show(message_or_query, context: ContextTypes.DEFAULT_TYPE) hb_remote = escape_markdown_v2(hb_git["remote_commit"]) hb_behind = hb_git["commits_behind"] hb_log_lines = hb_git["commit_log"].split("\n")[:5] - hb_log_display = "\n".join(escape_markdown_v2_code(l) for l in hb_log_lines) + hb_log_display = "\n".join( + escape_markdown_v2_code(l) for l in hb_log_lines + ) if hb_behind > 5: hb_log_display += f"\n_\\.\\.\\.and {hb_behind - 5} more_" sections.append( @@ -130,29 +131,51 @@ async def _check_and_show(message_or_query, context: ContextTypes.DEFAULT_TYPE) keyboard = [] if condor_has_update and hb_has_update: - keyboard.append([InlineKeyboardButton("Update All", callback_data="admin:update_all")]) - keyboard.append([ - InlineKeyboardButton("Update Condor", callback_data="admin:update_pull"), - InlineKeyboardButton("Update HB API", callback_data="admin:update_hb"), - ]) + keyboard.append( + [InlineKeyboardButton("Update All", callback_data="admin:update_all")] + ) + keyboard.append( + [ + InlineKeyboardButton( + "Update Condor", callback_data="admin:update_pull" + ), + InlineKeyboardButton("Update HB API", callback_data="admin:update_hb"), + ] + ) elif condor_has_update: - keyboard.append([InlineKeyboardButton("Update Condor & Restart", callback_data="admin:update_pull")]) + keyboard.append( + [ + InlineKeyboardButton( + "Update Condor & Restart", callback_data="admin:update_pull" + ) + ] + ) elif hb_has_update: - keyboard.append([InlineKeyboardButton("Update HB API", callback_data="admin:update_hb")]) + keyboard.append( + [InlineKeyboardButton("Update HB API", callback_data="admin:update_hb")] + ) - keyboard.append([InlineKeyboardButton("Refresh", callback_data="admin:update_check")]) - keyboard.append([InlineKeyboardButton("Force Restart", callback_data="admin:update_restart")]) + keyboard.append( + [InlineKeyboardButton("Refresh", callback_data="admin:update_check")] + ) + keyboard.append( + [InlineKeyboardButton("Force Restart", callback_data="admin:update_restart")] + ) keyboard.append([InlineKeyboardButton("Back", callback_data="admin:back")]) reply_markup = InlineKeyboardMarkup(keyboard) if is_callback: await message_or_query.edit_message_text( - text, parse_mode="MarkdownV2", reply_markup=reply_markup, + text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, ) else: await msg.edit_text( - text, parse_mode="MarkdownV2", reply_markup=reply_markup, + text, + parse_mode="MarkdownV2", + reply_markup=reply_markup, ) @@ -226,12 +249,18 @@ async def _fail( keyboard = [] if retry_restart: keyboard.append( - [InlineKeyboardButton("Restart Anyway", callback_data="admin:update_restart")] + [ + InlineKeyboardButton( + "Restart Anyway", callback_data="admin:update_restart" + ) + ] ) keyboard.append([InlineKeyboardButton("Back", callback_data="admin:update_check")]) await query.edit_message_text( - text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), + text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), ) @@ -263,7 +292,9 @@ async def _update_condor(query) -> bool: success, dep_msg = await install_dependencies() if not success: await _fail( - query, "Dependencies failed", dep_msg, + query, + "Dependencies failed", + dep_msg, note="Code was pulled but deps failed. Fix it manually before restarting.", retry_restart=True, ) @@ -276,7 +307,9 @@ async def _update_condor(query) -> bool: success, build_msg = await build_frontend() if not success: await _fail( - query, "Dashboard build failed", build_msg, + query, + "Dashboard build failed", + build_msg, note=( "Code and deps are updated, but the dashboard would come back " "on the previous bundle." @@ -324,7 +357,8 @@ async def _do_update_hb(query, context: ContextTypes.DEFAULT_TYPE) -> None: [InlineKeyboardButton("Back", callback_data="admin:update_check")], ] await query.edit_message_text( - text, parse_mode="MarkdownV2", + text, + parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), ) @@ -338,7 +372,9 @@ async def _do_update_all(query, context: ContextTypes.DEFAULT_TYPE) -> None: await _progress(query, "Updating hummingbot-api...") hb_ok, hb_msg = await update_hb_api() if not hb_ok: - await _fail(query, "HB API update failed", hb_msg, note="Condor update skipped.") + await _fail( + query, "HB API update failed", hb_msg, note="Condor update skipped." + ) return if await _update_condor(query): @@ -427,6 +463,4 @@ def schedule_update_checks(application) -> None: first=30, # first check 30s after startup name=UPDATE_CHECK_JOB, ) - logger.info( - "Scheduled update checks every %ds", UPDATE_CHECK_INTERVAL - ) + logger.info("Scheduled update checks every %ds", UPDATE_CHECK_INTERVAL) diff --git a/handlers/agents/openrouter_models.py b/handlers/agents/openrouter_models.py index 698ec2e8..8a7b17b5 100644 --- a/handlers/agents/openrouter_models.py +++ b/handlers/agents/openrouter_models.py @@ -24,8 +24,8 @@ @dataclass(frozen=True) class OpenRouterModel: - slug: str # e.g. "anthropic/claude-sonnet-4-5" - name: str # human-friendly name from the API + slug: str # e.g. "anthropic/claude-sonnet-4-5" + name: str # human-friendly name from the API context_length: int # tokens prompt_price: float # USD per 1M input tokens, 0 if free completion_price: float diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py index 3ef6e6b3..48d12484 100644 --- a/handlers/bots/__init__.py +++ b/handlers/bots/__init__.py @@ -108,8 +108,6 @@ handle_pv1_wizard_connector, handle_pv1_wizard_pair, handle_pv1_wizard_spreads, - process_pv1_wizard_input, - show_new_pmm_v1_form, handle_save_config, handle_select_all, handle_select_connector, @@ -128,6 +126,7 @@ process_gs_wizard_input, process_instance_name_input, process_pmm_wizard_input, + process_pv1_wizard_input, show_cfg_edit_form, show_config_form, show_configs_by_type, @@ -139,6 +138,7 @@ show_deploy_menu, show_new_grid_strike_form, show_new_pmm_mister_form, + show_new_pmm_v1_form, show_type_selector, show_upload_config_prompt, ) diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py index e6d65a35..9786ebc3 100644 --- a/handlers/bots/controller_handlers.py +++ b/handlers/bots/controller_handlers.py @@ -29,8 +29,6 @@ ) from utils.telegram_formatters import escape_markdown_v2, format_error_message -from .controllers import get_controller_info, get_supported_controller_types - from ._shared import ( GRID_STRIKE_DEFAULTS, GRID_STRIKE_FIELD_ORDER, @@ -56,6 +54,7 @@ init_new_controller_config, set_controller_config, ) +from .controllers import get_controller_info, get_supported_controller_types from .controllers.grid_strike.grid_analysis import ( calculate_natr, generate_theoretical_grid, @@ -8016,11 +8015,11 @@ async def _pmm_show_advanced(context, chat_id, message_id, config): # PMM V1 WIZARD # ============================================ -from .controllers.pmm_v1 import generate_id as pv1_generate_id -from .controllers.pmm_v1 import validate_config as pv1_validate_config -from .controllers.pmm_v1 import parse_spreads as pv1_parse_spreads from .controllers.pmm_v1 import FIELD_ORDER as PV1_FIELD_ORDER from .controllers.pmm_v1 import FIELDS as PV1_FIELDS +from .controllers.pmm_v1 import generate_id as pv1_generate_id +from .controllers.pmm_v1 import parse_spreads as pv1_parse_spreads +from .controllers.pmm_v1 import validate_config as pv1_validate_config async def show_new_pmm_v1_form( diff --git a/handlers/bots/menu.py b/handlers/bots/menu.py index 3e895853..34df06a1 100644 --- a/handlers/bots/menu.py +++ b/handlers/bots/menu.py @@ -951,9 +951,7 @@ async def handle_confirm_stop_controller( keyboard = [ [ InlineKeyboardButton("▶️ Restart", callback_data="bots:start_ctrl"), - InlineKeyboardButton( - "⬅️ Back to Bot", callback_data="bots:back_to_bot" - ), + InlineKeyboardButton("⬅️ Back to Bot", callback_data="bots:back_to_bot"), ] ] diff --git a/handlers/config/gateway/menu.py b/handlers/config/gateway/menu.py index 1b5914a5..24299ac6 100644 --- a/handlers/config/gateway/menu.py +++ b/handlers/config/gateway/menu.py @@ -62,7 +62,8 @@ async def show_gateway_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: "🪙 Tokens", callback_data="gateway_tokens" ), InlineKeyboardButton( - "📡 RPC Providers", callback_data="gateway_rpc_providers" + "📡 RPC Providers", + callback_data="gateway_rpc_providers", ), ], [ diff --git a/handlers/config/gateway/networks.py b/handlers/config/gateway/networks.py index 6c595ef2..6d17377d 100644 --- a/handlers/config/gateway/networks.py +++ b/handlers/config/gateway/networks.py @@ -6,7 +6,12 @@ from telegram.ext import ContextTypes from ..user_preferences import get_active_server -from ._shared import escape_markdown_v2, extract_network_id, get_default_networks, logger +from ._shared import ( + escape_markdown_v2, + extract_network_id, + get_default_networks, + logger, +) async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -218,10 +223,10 @@ async def show_network_details( [ InlineKeyboardButton( toggle_text, - callback_data=f"gateway_network_toggle_default_{network_id}" + callback_data=f"gateway_network_toggle_default_{network_id}", ) ], - [InlineKeyboardButton("« Back", callback_data="gateway_networks")] + [InlineKeyboardButton("« Back", callback_data="gateway_networks")], ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -471,8 +476,7 @@ async def toggle_default_network( # Update the config await client.gateway.update_network_config( - network_id, - {"default_networks": default_networks} + network_id, {"default_networks": default_networks} ) # Show success and refresh diff --git a/handlers/config/gateway/pools.py b/handlers/config/gateway/pools.py index 8f9e28e3..3069cbe1 100644 --- a/handlers/config/gateway/pools.py +++ b/handlers/config/gateway/pools.py @@ -57,7 +57,8 @@ async def show_pools_menu( else: # Filter to only default networks networks_to_show = [ - n for n in all_networks + n + for n in all_networks if extract_network_id(n) in default_network_ids ][:20] showing_defaults = True @@ -89,14 +90,14 @@ async def show_pools_menu( [ InlineKeyboardButton( f"🌐 All Networks ({len(all_networks)})", - callback_data="gateway_pool_all_networks" + callback_data="gateway_pool_all_networks", ) ], [ InlineKeyboardButton( "« Back to Gateway", callback_data="config_gateway" ) - ] + ], ] else: count_escaped = escape_markdown_v2(str(len(all_networks))) @@ -651,9 +652,7 @@ async def remove_pool( chat_id, preferred_server=get_active_server(context.user_data) ) await client.gateway.delete_network_pool( - network_id=network_id, - address=pool_address, - pool_type=pool_type + network_id=network_id, address=pool_address, pool_type=pool_type ) network_escaped = escape_markdown_v2(network_id) diff --git a/handlers/config/gateway/rpc_providers.py b/handlers/config/gateway/rpc_providers.py index f7760a6b..cf6ad62d 100644 --- a/handlers/config/gateway/rpc_providers.py +++ b/handlers/config/gateway/rpc_providers.py @@ -8,7 +8,6 @@ from ..user_preferences import get_active_server from ._shared import escape_markdown_v2, logger - # RPC Provider configuration # Maps provider name to chain and default network RPC_PROVIDERS = { @@ -72,12 +71,13 @@ async def show_rpc_providers_menu(query, context: ContextTypes.DEFAULT_TYPE) -> chain_label = chain.capitalize() button_text = f"{status} {provider_info['name']} ({chain_label})" - provider_buttons.append([ - InlineKeyboardButton( - button_text, - callback_data=f"gateway_rpc_{provider_key}" - ) - ]) + provider_buttons.append( + [ + InlineKeyboardButton( + button_text, callback_data=f"gateway_rpc_{provider_key}" + ) + ] + ) message_text = ( "📡 *RPC Providers*\n\n" @@ -88,8 +88,12 @@ async def show_rpc_providers_menu(query, context: ContextTypes.DEFAULT_TYPE) -> ) keyboard = provider_buttons + [ - [InlineKeyboardButton("🔗 Custom URL", callback_data="gateway_rpc_url_menu")], - [InlineKeyboardButton("« Back", callback_data="config_gateway")] + [ + InlineKeyboardButton( + "🔗 Custom URL", callback_data="gateway_rpc_url_menu" + ) + ], + [InlineKeyboardButton("« Back", callback_data="config_gateway")], ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -182,7 +186,9 @@ async def show_provider_details( # API key status if has_key: # Mask the API key for display (show first 4 chars) - masked_key = current_key[:4] + "..." if len(current_key) > 4 else current_key + masked_key = ( + current_key[:4] + "..." if len(current_key) > 4 else current_key + ) key_status = f"🔑 API Key: `{escape_markdown_v2(masked_key)}`" else: key_status = "⬜ No API key configured" @@ -207,40 +213,48 @@ async def show_provider_details( # API key button if has_key: - keyboard.append([ - InlineKeyboardButton( - "🔑 Update API Key", - callback_data=f"gateway_rpc_setkey_{provider_key}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + "🔑 Update API Key", + callback_data=f"gateway_rpc_setkey_{provider_key}", + ) + ] + ) else: - keyboard.append([ - InlineKeyboardButton( - "➕ Add API Key", - callback_data=f"gateway_rpc_setkey_{provider_key}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + "➕ Add API Key", + callback_data=f"gateway_rpc_setkey_{provider_key}", + ) + ] + ) # Activate/Deactivate button (only if has key) if has_key: if is_active: - keyboard.append([ - InlineKeyboardButton( - "⬜ Deactivate (use custom URL)", - callback_data=f"gateway_rpc_deactivate_{provider_key}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + "⬜ Deactivate (use custom URL)", + callback_data=f"gateway_rpc_deactivate_{provider_key}", + ) + ] + ) else: - keyboard.append([ - InlineKeyboardButton( - "✅ Activate as RPC Provider", - callback_data=f"gateway_rpc_activate_{provider_key}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + "✅ Activate as RPC Provider", + callback_data=f"gateway_rpc_activate_{provider_key}", + ) + ] + ) - keyboard.append([ - InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers") - ]) + keyboard.append( + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")] + ) reply_markup = InlineKeyboardMarkup(keyboard) @@ -251,7 +265,9 @@ async def show_provider_details( except Exception as e: logger.error(f"Error showing provider details: {e}", exc_info=True) error_text = f"❌ Error: {escape_markdown_v2(str(e))}" - keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")]] + keyboard = [ + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")] + ] reply_markup = InlineKeyboardMarkup(keyboard) await query.message.edit_text( error_text, parse_mode="MarkdownV2", reply_markup=reply_markup @@ -324,21 +340,24 @@ async def _handle_api_key_input( try: logger.info(f"Processing API key input for provider: {provider_key}") - api_key = update.message.text.strip() if update.message and update.message.text else "" + api_key = ( + update.message.text.strip() + if update.message and update.message.text + else "" + ) provider_info = RPC_PROVIDERS.get(provider_key) if not provider_info: logger.error(f"Unknown provider: {provider_key}") await update.get_bot().send_message( chat_id=update.effective_chat.id, - text=f"❌ Unknown provider: {provider_key}" + text=f"❌ Unknown provider: {provider_key}", ) return if not api_key: await update.get_bot().send_message( - chat_id=update.effective_chat.id, - text="❌ API key cannot be empty" + chat_id=update.effective_chat.id, text="❌ API key cannot be empty" ) return @@ -348,7 +367,7 @@ async def _handle_api_key_input( await update.get_bot().edit_message_text( chat_id=chat_id, message_id=message_id, - text=f"💾 Saving {provider_info['name']} API key..." + text=f"💾 Saving {provider_info['name']} API key...", ) except Exception as edit_err: logger.debug(f"Could not edit message: {edit_err}") @@ -359,7 +378,7 @@ async def _handle_api_key_input( logger.info(f"Getting client for chat {update.effective_chat.id}") client = await get_config_manager().get_client_for_chat( update.effective_chat.id, - preferred_server=get_active_server(context.user_data) + preferred_server=get_active_server(context.user_data), ) # Step 1: Update API key @@ -371,8 +390,7 @@ async def _handle_api_key_input( network_id = provider_info["default_network"] logger.info(f"Setting rpc_provider to {provider_key} for {network_id}") config_result = await client.gateway.update_network_config( - network_id, - {"rpc_provider": provider_key} + network_id, {"rpc_provider": provider_key} ) logger.info(f"Network config update result: {config_result}") @@ -385,8 +403,16 @@ async def _handle_api_key_input( ) keyboard = [ - [InlineKeyboardButton("🔄 Restart Gateway", callback_data="gateway_restart")], - [InlineKeyboardButton("« Back", callback_data=f"gateway_rpc_{provider_key}")] + [ + InlineKeyboardButton( + "🔄 Restart Gateway", callback_data="gateway_restart" + ) + ], + [ + InlineKeyboardButton( + "« Back", callback_data=f"gateway_rpc_{provider_key}" + ) + ], ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -397,7 +423,7 @@ async def _handle_api_key_input( message_id=message_id, text=success_text, parse_mode="MarkdownV2", - reply_markup=reply_markup + reply_markup=reply_markup, ) except Exception as edit_err: logger.warning(f"Could not edit message, sending new: {edit_err}") @@ -405,14 +431,14 @@ async def _handle_api_key_input( chat_id=update.effective_chat.id, text=success_text, parse_mode="MarkdownV2", - reply_markup=reply_markup + reply_markup=reply_markup, ) else: await update.get_bot().send_message( chat_id=update.effective_chat.id, text=success_text, parse_mode="MarkdownV2", - reply_markup=reply_markup + reply_markup=reply_markup, ) logger.info(f"Successfully configured {provider_key}") @@ -422,7 +448,7 @@ async def _handle_api_key_input( try: await update.get_bot().send_message( chat_id=update.effective_chat.id, - text=f"❌ Error saving API key: {str(e)}" + text=f"❌ Error saving API key: {str(e)}", ) except Exception as send_err: logger.error(f"Could not send error message: {send_err}") @@ -440,14 +466,17 @@ async def _handle_url_input( try: # Extract network_id from input_type (format: "url_{network_id}") network_id = input_type.replace("url_", "") - node_url = update.message.text.strip() if update.message and update.message.text else "" + node_url = ( + update.message.text.strip() + if update.message and update.message.text + else "" + ) logger.info(f"Processing URL input for network: {network_id}") if not node_url: await update.get_bot().send_message( - chat_id=update.effective_chat.id, - text="❌ URL cannot be empty" + chat_id=update.effective_chat.id, text="❌ URL cannot be empty" ) return @@ -455,7 +484,7 @@ async def _handle_url_input( if not node_url.startswith(("http://", "https://")): await update.get_bot().send_message( chat_id=update.effective_chat.id, - text="❌ URL must start with http:// or https://" + text="❌ URL must start with http:// or https://", ) return @@ -465,7 +494,7 @@ async def _handle_url_input( await update.get_bot().edit_message_text( chat_id=chat_id, message_id=message_id, - text=f"💾 Saving node URL for {network_id}..." + text=f"💾 Saving node URL for {network_id}...", ) except Exception as edit_err: logger.debug(f"Could not edit message: {edit_err}") @@ -475,7 +504,7 @@ async def _handle_url_input( client = await get_config_manager().get_client_for_chat( update.effective_chat.id, - preferred_server=get_active_server(context.user_data) + preferred_server=get_active_server(context.user_data), ) # Get current rpc_provider before updating @@ -488,8 +517,7 @@ async def _handle_url_input( # Update only node_url (not rpc_provider yet) logger.info(f"Updating node_url for {network_id}") result = await client.gateway.update_network_config( - network_id, - {"node_url": node_url} + network_id, {"node_url": node_url} ) logger.info(f"Network config update result: {result}") @@ -504,14 +532,18 @@ async def _handle_url_input( f"_Do you want to use the new nodeURL for this network?_" ) keyboard = [ - [InlineKeyboardButton( - "✅ Yes, use new URL", - callback_data=f"gateway_rpc_url_activate_{network_id}" - )], - [InlineKeyboardButton( - f"Keep using {current_rpc}", - callback_data="gateway_rpc_url_menu" - )], + [ + InlineKeyboardButton( + "✅ Yes, use new URL", + callback_data=f"gateway_rpc_url_activate_{network_id}", + ) + ], + [ + InlineKeyboardButton( + f"Keep using {current_rpc}", + callback_data="gateway_rpc_url_menu", + ) + ], ] else: success_text = ( @@ -519,8 +551,12 @@ async def _handle_url_input( f"_Restart Gateway for changes to take effect\\._" ) keyboard = [ - [InlineKeyboardButton("🔄 Restart Gateway", callback_data="gateway_restart")], - [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")] + [ + InlineKeyboardButton( + "🔄 Restart Gateway", callback_data="gateway_restart" + ) + ], + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")], ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -532,7 +568,7 @@ async def _handle_url_input( message_id=message_id, text=success_text, parse_mode="MarkdownV2", - reply_markup=reply_markup + reply_markup=reply_markup, ) except Exception as edit_err: logger.warning(f"Could not edit message, sending new: {edit_err}") @@ -540,14 +576,14 @@ async def _handle_url_input( chat_id=update.effective_chat.id, text=success_text, parse_mode="MarkdownV2", - reply_markup=reply_markup + reply_markup=reply_markup, ) else: await update.get_bot().send_message( chat_id=update.effective_chat.id, text=success_text, parse_mode="MarkdownV2", - reply_markup=reply_markup + reply_markup=reply_markup, ) logger.info(f"Successfully updated URL for {network_id}") @@ -556,8 +592,7 @@ async def _handle_url_input( logger.error(f"Error handling URL input: {e}", exc_info=True) try: await update.get_bot().send_message( - chat_id=update.effective_chat.id, - text=f"❌ Error saving URL: {str(e)}" + chat_id=update.effective_chat.id, text=f"❌ Error saving URL: {str(e)}" ) except Exception as send_err: logger.error(f"Could not send error message: {send_err}") @@ -585,8 +620,7 @@ async def activate_provider( # Update rpcProvider on the default network network_id = provider_info["default_network"] await client.gateway.update_network_config( - network_id, - {"rpc_provider": provider_key} + network_id, {"rpc_provider": provider_key} ) await query.answer(f"✅ {provider_info['name']} activated! Restart Gateway.") @@ -620,10 +654,7 @@ async def deactivate_provider( # Set rpcProvider back to "url" (custom) network_id = provider_info["default_network"] - await client.gateway.update_network_config( - network_id, - {"rpc_provider": "url"} - ) + await client.gateway.update_network_config(network_id, {"rpc_provider": "url"}) await query.answer(f"✅ {provider_info['name']} deactivated. Restart Gateway.") @@ -650,10 +681,7 @@ async def activate_custom_url( ) # Set rpcProvider to "url" - await client.gateway.update_network_config( - network_id, - {"rpc_provider": "url"} - ) + await client.gateway.update_network_config(network_id, {"rpc_provider": "url"}) network_escaped = escape_markdown_v2(network_id) success_text = ( @@ -662,8 +690,12 @@ async def activate_custom_url( ) keyboard = [ - [InlineKeyboardButton("🔄 Restart Gateway", callback_data="gateway_restart")], - [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")] + [ + InlineKeyboardButton( + "🔄 Restart Gateway", callback_data="gateway_restart" + ) + ], + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")], ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -680,6 +712,7 @@ async def activate_custom_url( # Custom URL Configuration # ============================================ + async def show_url_networks_menu( query, context: ContextTypes.DEFAULT_TYPE, show_all: bool = False ) -> None: @@ -718,7 +751,8 @@ async def show_url_networks_menu( showing_defaults = False else: networks_to_show = [ - n for n in all_networks + n + for n in all_networks if extract_network_id(n) in default_network_ids ][:20] showing_defaults = True @@ -730,12 +764,13 @@ async def show_url_networks_menu( network_buttons = [] for idx, network_item in enumerate(networks_to_show): network_id = extract_network_id(network_item) - network_buttons.append([ - InlineKeyboardButton( - network_id, - callback_data=f"gateway_rpc_url_net_{idx}" - ) - ]) + network_buttons.append( + [ + InlineKeyboardButton( + network_id, callback_data=f"gateway_rpc_url_net_{idx}" + ) + ] + ) if showing_defaults: count_escaped = escape_markdown_v2(str(len(networks_to_show))) @@ -747,10 +782,14 @@ async def show_url_networks_menu( [ InlineKeyboardButton( f"🌐 All Networks ({len(all_networks)})", - callback_data="gateway_rpc_url_all" + callback_data="gateway_rpc_url_all", + ) + ], + [ + InlineKeyboardButton( + "« Back", callback_data="gateway_rpc_providers" ) ], - [InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")] ] else: count_escaped = escape_markdown_v2(str(len(all_networks))) @@ -759,7 +798,11 @@ async def show_url_networks_menu( "_Select a network to view and edit RPC settings:_" ) keyboard = network_buttons + [ - [InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")] + [ + InlineKeyboardButton( + "« Back", callback_data="gateway_rpc_providers" + ) + ] ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -771,7 +814,9 @@ async def show_url_networks_menu( except Exception as e: logger.error(f"Error showing URL networks menu: {e}", exc_info=True) error_text = f"❌ Error loading networks: {escape_markdown_v2(str(e))}" - keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")]] + keyboard = [ + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_providers")] + ] reply_markup = InlineKeyboardMarkup(keyboard) await query.message.edit_text( error_text, parse_mode="MarkdownV2", reply_markup=reply_markup @@ -830,11 +875,10 @@ async def show_network_rpc_config( keyboard = [ [ InlineKeyboardButton( - "✏️ Edit URL", - callback_data=f"gateway_rpc_url_edit_{network_id}" + "✏️ Edit URL", callback_data=f"gateway_rpc_url_edit_{network_id}" ) ], - [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")] + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")], ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -846,7 +890,9 @@ async def show_network_rpc_config( except Exception as e: logger.error(f"Error showing network RPC config: {e}", exc_info=True) error_text = f"❌ Error: {escape_markdown_v2(str(e))}" - keyboard = [[InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")]] + keyboard = [ + [InlineKeyboardButton("« Back", callback_data="gateway_rpc_url_menu")] + ] reply_markup = InlineKeyboardMarkup(keyboard) await query.message.edit_text( error_text, parse_mode="MarkdownV2", reply_markup=reply_markup diff --git a/handlers/config/gateway/tokens.py b/handlers/config/gateway/tokens.py index d37fdf3e..e95d4264 100644 --- a/handlers/config/gateway/tokens.py +++ b/handlers/config/gateway/tokens.py @@ -7,7 +7,12 @@ from telegram.ext import ContextTypes from ..user_preferences import get_active_server -from ._shared import escape_markdown_v2, extract_network_id, get_default_networks, logger +from ._shared import ( + escape_markdown_v2, + extract_network_id, + get_default_networks, + logger, +) # Gateway network ID -> GeckoTerminal network ID mapping NETWORK_TO_GECKO = { @@ -70,7 +75,8 @@ async def show_tokens_menu( else: # Filter to only default networks networks_to_show = [ - n for n in all_networks + n + for n in all_networks if extract_network_id(n) in default_network_ids ][:20] showing_defaults = True @@ -103,14 +109,14 @@ async def show_tokens_menu( [ InlineKeyboardButton( f"🌐 All Networks ({len(all_networks)})", - callback_data="gateway_token_all_networks" + callback_data="gateway_token_all_networks", ) ], [ InlineKeyboardButton( "« Back to Gateway", callback_data="config_gateway" ) - ] + ], ] else: count_escaped = escape_markdown_v2(str(len(all_networks))) diff --git a/handlers/config/gateway/wallets.py b/handlers/config/gateway/wallets.py index c6185578..b10e2b2f 100644 --- a/handlers/config/gateway/wallets.py +++ b/handlers/config/gateway/wallets.py @@ -94,7 +94,9 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: default_address = wallet_group.get("default_address", "") for address in addresses: is_default = address == default_address - wallet_list.append({"chain": chain, "address": address, "is_default": is_default}) + wallet_list.append( + {"chain": chain, "address": address, "is_default": is_default} + ) context.user_data["wallet_list"] = wallet_list total_wallets = len(wallet_list) @@ -119,7 +121,9 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: "🟣" if chain == "solana" else "🔵" ) # Solana purple, Ethereum blue default_indicator = " ⭐️" if is_default else "" - button_text = f"{chain_icon} {chain.title()}: {display_addr}{default_indicator}" + button_text = ( + f"{chain_icon} {chain.title()}: {display_addr}{default_indicator}" + ) wallet_buttons.append( [ InlineKeyboardButton( @@ -232,7 +236,9 @@ async def handle_wallet_action(query, context: ContextTypes.DEFAULT_TYPE) -> Non wallet_list = context.user_data.get("wallet_list", []) if 0 <= idx < len(wallet_list): wallet = wallet_list[idx] - await set_default_wallet(query, context, wallet["chain"], wallet["address"]) + await set_default_wallet( + query, context, wallet["chain"], wallet["address"] + ) else: await query.answer("❌ Wallet not found") except ValueError: @@ -417,11 +423,7 @@ async def show_wallet_details( ) keyboard.append( - [ - InlineKeyboardButton( - "« Back to Wallets", callback_data="gateway_wallets" - ) - ] + [InlineKeyboardButton("« Back to Wallets", callback_data="gateway_wallets")] ) reply_markup = InlineKeyboardMarkup(keyboard) diff --git a/handlers/delegations.py b/handlers/delegations.py index 438da11d..0db96e35 100644 --- a/handlers/delegations.py +++ b/handlers/delegations.py @@ -20,11 +20,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes -from condor.agents.delegate import ( - get_all_delegations, - get_delegation, - stop_delegation, -) +from condor.agents.delegate import get_all_delegations, get_delegation, stop_delegation from handlers import clear_all_input_states from utils.auth import restricted from utils.telegram_formatters import escape_markdown_v2 diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py index 21098270..ff8762f0 100644 --- a/handlers/dex/_shared.py +++ b/handlers/dex/_shared.py @@ -11,16 +11,14 @@ import logging from typing import Any, Callable, Dict, List, Optional -from condor.cache import ( - DEFAULT_CACHE_TTL, - clear_cache as _clear_cache, - evict_expired as _evict_expired, - get_cached as _get_cached, - invalidate_groups as _invalidate_groups, - invalidates as _invalidates, - set_cached as _set_cached, - cached_call as _cached_call, -) +from condor.cache import DEFAULT_CACHE_TTL +from condor.cache import cached_call as _cached_call +from condor.cache import clear_cache as _clear_cache +from condor.cache import evict_expired as _evict_expired +from condor.cache import get_cached as _get_cached +from condor.cache import invalidate_groups as _invalidate_groups +from condor.cache import invalidates as _invalidates +from condor.cache import set_cached as _set_cached logger = logging.getLogger(__name__) @@ -32,7 +30,9 @@ _NS = "_cache" # namespace for DEX cache -def get_cached(user_data: dict, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Optional[Any]: +def get_cached( + user_data: dict, key: str, ttl: int = DEFAULT_CACHE_TTL +) -> Optional[Any]: return _get_cached(user_data, key, ttl, namespace=_NS) @@ -56,7 +56,9 @@ async def cached_call( *args, **kwargs, ) -> Any: - return await _cached_call(user_data, key, fetch_func, ttl, *args, namespace=_NS, **kwargs) + return await _cached_call( + user_data, key, fetch_func, ttl, *args, namespace=_NS, **kwargs + ) # ============================================ diff --git a/handlers/executors/__init__.py b/handlers/executors/__init__.py index c0ad7e3a..4a9118e9 100644 --- a/handlers/executors/__init__.py +++ b/handlers/executors/__init__.py @@ -87,14 +87,9 @@ async def executors_callback_handler( # Import handlers lazily to avoid circular imports from .grid import handle_connector_select as grid_handle_connector_select from .grid import handle_deploy as grid_handle_deploy - from .grid import ( - handle_interval_select, - ) + from .grid import handle_interval_select from .grid import handle_pair_input as grid_handle_pair_input - from .grid import ( - show_step_2_combined, - start_grid_wizard, - ) + from .grid import show_step_2_combined, start_grid_wizard from .menu import ( handle_close, handle_confirm_stop_executor, @@ -107,14 +102,9 @@ async def executors_callback_handler( ) from .position import handle_connector_select as pos_handle_connector_select from .position import handle_deploy as pos_handle_deploy - from .position import ( - handle_entry_price_select as pos_handle_entry_price, - ) + from .position import handle_entry_price_select as pos_handle_entry_price from .position import handle_pair_input as pos_handle_pair_input - from .position import ( - show_step_2_config, - start_position_wizard, - ) + from .position import show_step_2_config, start_position_wizard # Menu actions if action == "menu": diff --git a/handlers/executors/grid.py b/handlers/executors/grid.py index 9bdc8789..343bf36a 100644 --- a/handlers/executors/grid.py +++ b/handlers/executors/grid.py @@ -48,7 +48,11 @@ set_executor_config, ) -ORDER_TYPE_LABELS = {ORDER_TYPE_MARKET: "MARKET", ORDER_TYPE_LIMIT: "LIMIT", ORDER_TYPE_LIMIT_MAKER: "LIMIT_MAKER"} +ORDER_TYPE_LABELS = { + ORDER_TYPE_MARKET: "MARKET", + ORDER_TYPE_LIMIT: "LIMIT", + ORDER_TYPE_LIMIT_MAKER: "LIMIT_MAKER", +} logger = logging.getLogger(__name__) @@ -94,8 +98,12 @@ def _format_config_block(config: Dict[str, Any]) -> str: coerce_tp = config.get("coerce_tp_to_step", False) keep_position = config.get("keep_position", False) - open_ot = ORDER_TYPE_LABELS.get(config.get("open_order_type", ORDER_TYPE_LIMIT), "LIMIT") - tp_ot = ORDER_TYPE_LABELS.get(config.get("take_profit_order_type", ORDER_TYPE_LIMIT), "LIMIT") + open_ot = ORDER_TYPE_LABELS.get( + config.get("open_order_type", ORDER_TYPE_LIMIT), "LIMIT" + ) + tp_ot = ORDER_TYPE_LABELS.get( + config.get("take_profit_order_type", ORDER_TYPE_LIMIT), "LIMIT" + ) lines = [ f"side={side_label}", @@ -449,7 +457,9 @@ async def handle_pair_input( pair = correct_pair else: # Fallback: Get correctly formatted pair from trading rules - trading_rules = await get_trading_rules(context.user_data, client, connector) + trading_rules = await get_trading_rules( + context.user_data, client, connector + ) fallback_pair = get_correct_pair_format(trading_rules, pair) if fallback_pair: pair = fallback_pair @@ -849,7 +859,11 @@ async def handle_config_input( # Handle order type fields: accept MARKET/LIMIT/LIMIT_MAKER or 1/2/3 if key in ("open_order_type", "take_profit_order_type"): - ot_map = {"market": ORDER_TYPE_MARKET, "limit": ORDER_TYPE_LIMIT, "limit_maker": ORDER_TYPE_LIMIT_MAKER} + ot_map = { + "market": ORDER_TYPE_MARKET, + "limit": ORDER_TYPE_LIMIT, + "limit_maker": ORDER_TYPE_LIMIT_MAKER, + } val_lower = value.lower() if val_lower in ot_map: updates[key] = ot_map[val_lower] @@ -977,7 +991,9 @@ async def handle_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N "triple_barrier_config": { "take_profit": config.get("take_profit", 0.0002), "open_order_type": config.get("open_order_type", ORDER_TYPE_LIMIT), - "take_profit_order_type": config.get("take_profit_order_type", ORDER_TYPE_LIMIT), + "take_profit_order_type": config.get( + "take_profit_order_type", ORDER_TYPE_LIMIT + ), }, } diff --git a/handlers/executors/menu.py b/handlers/executors/menu.py index 665a4dbb..7fc43f43 100644 --- a/handlers/executors/menu.py +++ b/handlers/executors/menu.py @@ -284,8 +284,7 @@ async def show_history( # so fetch without status filter and exclude running ones. result = await search_running_executors(client, status=None, limit=100) history = [ - ex for ex in result - if str(ex.get("status", "")).upper() != "RUNNING" + ex for ex in result if str(ex.get("status", "")).upper() != "RUNNING" ] # Sort by timestamp descending (most recent first) @@ -470,7 +469,9 @@ async def show_history_detail( context.user_data["current_executor_id"] = full_id # Reuse the detail rendering - await _render_executor_detail(update, context, executor, back_callback="executors:history") + await _render_executor_detail( + update, context, executor, back_callback="executors:history" + ) # ============================================ @@ -526,7 +527,9 @@ async def show_executor_detail( context.user_data["current_executor"] = executor context.user_data["current_executor_id"] = full_id - await _render_executor_detail(update, context, executor, back_callback="executors:menu") + await _render_executor_detail( + update, context, executor, back_callback="executors:menu" + ) except Exception as e: logger.error(f"Error showing executor detail: {e}", exc_info=True) @@ -602,9 +605,9 @@ async def _render_executor_detail( take_profit = config.get("take_profit", 0) or tbc.get("take_profit", 0) time_limit = config.get("time_limit", 0) or tbc.get("time_limit", 0) trailing_cfg = tbc.get("trailing_stop") or {} - trailing_act = config.get( - "trailing_stop_activation", 0 - ) or trailing_cfg.get("activation_price", 0) + trailing_act = config.get("trailing_stop_activation", 0) or trailing_cfg.get( + "activation_price", 0 + ) trailing_delta = config.get("trailing_stop_delta", 0) or trailing_cfg.get( "trailing_delta", 0 ) @@ -678,8 +681,12 @@ async def _render_executor_detail( mid_price = (start_price + end_price) / 2 grid_range = (end_price - start_price) / start_price min_step = max(min_spread, 0) - max_levels_by_amount = int(amount / min_order_quote) if min_order_quote else 1 - max_levels_by_step = int(grid_range / min_step) if min_step > 0 else max_levels_by_amount + max_levels_by_amount = ( + int(amount / min_order_quote) if min_order_quote else 1 + ) + max_levels_by_step = ( + int(grid_range / min_step) if min_step > 0 else max_levels_by_amount + ) n_levels = max(1, min(max_levels_by_amount, max_levels_by_step)) amount_per_level = amount / n_levels step = grid_range / max(n_levels - 1, 1) @@ -687,10 +694,16 @@ async def _render_executor_detail( lines.append("") lines.append(f"📏 *Grid Metrics*") - lines.append(f" Levels: `{n_levels}` \\| Step: `{escape_markdown_v2(f'{step:.4%}')}`") - lines.append(f" Per Level: `${escape_markdown_v2(f'{amount_per_level:,.2f}')}`") + lines.append( + f" Levels: `{n_levels}` \\| Step: `{escape_markdown_v2(f'{step:.4%}')}`" + ) + lines.append( + f" Per Level: `${escape_markdown_v2(f'{amount_per_level:,.2f}')}`" + ) if coerce_tp and eff_tp != take_profit: - lines.append(f" Eff\\. TP: `{escape_markdown_v2(f'{eff_tp:.4%}')}` \\(coerced to step\\)") + lines.append( + f" Eff\\. TP: `{escape_markdown_v2(f'{eff_tp:.4%}')}` \\(coerced to step\\)" + ) lines.append("") lines.append(f"📊 *Performance*") @@ -724,6 +737,7 @@ async def _render_executor_detail( # Created timestamp if created_at: from datetime import datetime, timezone + try: dt = datetime.fromtimestamp(created_at, tz=timezone.utc) created_str = dt.strftime("%m/%d %H:%M UTC") diff --git a/handlers/executors/position.py b/handlers/executors/position.py index 08343284..ef4ee23c 100644 --- a/handlers/executors/position.py +++ b/handlers/executors/position.py @@ -16,10 +16,7 @@ from telegram.error import BadRequest from telegram.ext import ContextTypes -from handlers.bots._shared import ( - fetch_current_price, - get_available_cex_connectors, -) +from handlers.bots._shared import fetch_current_price, get_available_cex_connectors from handlers.cex._shared import ( get_cex_balances, get_correct_pair_format, @@ -46,8 +43,12 @@ # Order type mapping ORDER_TYPE_MAP = { - "MARKET": 1, "LIMIT": 2, "LIMIT_MAKER": 3, - "1": 1, "2": 2, "3": 3, + "MARKET": 1, + "LIMIT": 2, + "LIMIT_MAKER": 3, + "1": 1, + "2": 2, + "3": 3, } ORDER_TYPE_LABELS = {1: "MARKET", 2: "LIMIT", 3: "LIMIT_MAKER"} @@ -82,7 +83,9 @@ def _is_perpetual(connector: str) -> bool: return "_perpetual" in connector.lower() -def _format_config_block(config: Dict[str, Any], current_price: Optional[float] = None) -> str: +def _format_config_block( + config: Dict[str, Any], current_price: Optional[float] = None +) -> str: """Format config as key=value block for display inside a code block.""" side = normalize_side(config.get("side", SIDE_LONG)) side_label = "LONG" if side == SIDE_LONG else "SHORT" @@ -213,33 +216,43 @@ def _build_step_2_text( return "\n".join(lines) -def _build_step_2_keyboard(current_price: Optional[float] = None) -> InlineKeyboardMarkup: +def _build_step_2_keyboard( + current_price: Optional[float] = None, +) -> InlineKeyboardMarkup: """Build the keyboard for step 2.""" keyboard = [] # Entry price quick-pick buttons if current_price and current_price > 0: - keyboard.append([ - InlineKeyboardButton("📊 Market", callback_data="executors:pos_entry:market"), - InlineKeyboardButton( - f"📊 Current ({current_price:,.6g})", - callback_data="executors:pos_entry:current", - ), - ]) - keyboard.append([ - InlineKeyboardButton("-2%", callback_data="executors:pos_entry:-2"), - InlineKeyboardButton("-1%", callback_data="executors:pos_entry:-1"), - InlineKeyboardButton("+1%", callback_data="executors:pos_entry:+1"), - InlineKeyboardButton("+2%", callback_data="executors:pos_entry:+2"), - ]) + keyboard.append( + [ + InlineKeyboardButton( + "📊 Market", callback_data="executors:pos_entry:market" + ), + InlineKeyboardButton( + f"📊 Current ({current_price:,.6g})", + callback_data="executors:pos_entry:current", + ), + ] + ) + keyboard.append( + [ + InlineKeyboardButton("-2%", callback_data="executors:pos_entry:-2"), + InlineKeyboardButton("-1%", callback_data="executors:pos_entry:-1"), + InlineKeyboardButton("+1%", callback_data="executors:pos_entry:+1"), + InlineKeyboardButton("+2%", callback_data="executors:pos_entry:+2"), + ] + ) keyboard.append( [InlineKeyboardButton("🚀 Deploy", callback_data="executors:pos_deploy")] ) - keyboard.append([ - InlineKeyboardButton("⬅️ Back", callback_data="executors:create_position"), - InlineKeyboardButton("❌ Cancel", callback_data="executors:menu"), - ]) + keyboard.append( + [ + InlineKeyboardButton("⬅️ Back", callback_data="executors:create_position"), + InlineKeyboardButton("❌ Cancel", callback_data="executors:menu"), + ] + ) return InlineKeyboardMarkup(keyboard) @@ -737,7 +750,12 @@ async def handle_config_input( continue # Handle order type fields: accept MARKET/LIMIT/LIMIT_MAKER or 1/2/3 - if key in ("open_order_type", "take_profit_order_type", "stop_loss_order_type", "time_limit_order_type"): + if key in ( + "open_order_type", + "take_profit_order_type", + "stop_loss_order_type", + "time_limit_order_type", + ): mapped = ORDER_TYPE_MAP.get(value.upper()) if mapped: updates[key] = mapped @@ -844,7 +862,8 @@ async def handle_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N set_executor_config(context, config) else: await query.answer( - "Could not fetch price to convert total_amount_quote", show_alert=True + "Could not fetch price to convert total_amount_quote", + show_alert=True, ) return except Exception as e: diff --git a/mcp_servers/condor/tools/context.py b/mcp_servers/condor/tools/context.py index 65fc05fb..8d94cdf8 100644 --- a/mcp_servers/condor/tools/context.py +++ b/mcp_servers/condor/tools/context.py @@ -21,11 +21,16 @@ async def get_user_context() -> dict: # New Agents inherit active_agent_key by default, so the coordinator should # never invent one — an invented key names a backend that may not exist. try: - from condor.preferences import get_active_agent_key, get_custom_providers, load_user_data_for + from condor.preferences import ( + get_active_agent_key, + get_custom_providers, + load_user_data_for, + ) context["active_agent_key"] = get_active_agent_key(settings.user_id) context["custom_llm_endpoints"] = [ - p["name"] for p in get_custom_providers(load_user_data_for(settings.user_id)) + p["name"] + for p in get_custom_providers(load_user_data_for(settings.user_id)) ] except Exception: context["active_agent_key"] = None diff --git a/mcp_servers/condor/tools/servers.py b/mcp_servers/condor/tools/servers.py index 7c15a935..8e79399b 100644 --- a/mcp_servers/condor/tools/servers.py +++ b/mcp_servers/condor/tools/servers.py @@ -15,13 +15,15 @@ def list_servers() -> dict: if not server: continue perm = cm.get_server_permission(settings.user_id, name) - servers.append({ - "name": name, - "host": server["host"], - "port": server["port"], - "permission": perm.value if perm else "unknown", - "is_active": name == active_server, - }) + servers.append( + { + "name": name, + "host": server["host"], + "port": server["port"], + "permission": perm.value if perm else "unknown", + "is_active": name == active_server, + } + ) return {"servers": servers, "active_server": active_server} diff --git a/mcp_servers/hummingbot_api/executor_preferences.py b/mcp_servers/hummingbot_api/executor_preferences.py index 2fe3d8ae..2e054313 100644 --- a/mcp_servers/hummingbot_api/executor_preferences.py +++ b/mcp_servers/hummingbot_api/executor_preferences.py @@ -6,6 +6,7 @@ Preferences are stored at: ~/.hummingbot_mcp/executor_preferences.md """ + import logging import re from pathlib import Path @@ -143,7 +144,9 @@ def _ensure_preferences_exist(self) -> None: # Create default preferences file if it doesn't exist if not self.preferences_path.exists(): self._write_template() - logger.info(f"Created default executor preferences at {self.preferences_path}") + logger.info( + f"Created default executor preferences at {self.preferences_path}" + ) def _write_template(self) -> None: """Write the default template to the preferences file.""" @@ -169,7 +172,7 @@ def _parse_yaml_blocks(self, content: str) -> dict[str, dict[str, Any]]: Dictionary mapping executor type to its configuration """ # Pattern to match YAML code blocks - yaml_pattern = r'```yaml\s*\n([\s\S]*?)```' + yaml_pattern = r"```yaml\s*\n([\s\S]*?)```" defaults = {} matches = re.findall(yaml_pattern, content) @@ -244,12 +247,14 @@ def update_defaults(self, executor_type: str, config: dict[str, Any]) -> None: merged_config = {**existing_defaults, **config} # Create the new YAML block - new_yaml = yaml.dump({executor_type: merged_config}, default_flow_style=False, sort_keys=False) + new_yaml = yaml.dump( + {executor_type: merged_config}, default_flow_style=False, sort_keys=False + ) new_block = f"```yaml\n{new_yaml}```" # Pattern to find the existing block for this executor type # Look for ```yaml followed by the executor type key - pattern = rf'```yaml\s*\n{re.escape(executor_type)}:[\s\S]*?```' + pattern = rf"```yaml\s*\n{re.escape(executor_type)}:[\s\S]*?```" if re.search(pattern, content): # Replace existing block @@ -260,23 +265,22 @@ def update_defaults(self, executor_type: str, config: dict[str, Any]) -> None: section_header = f"### {executor_type.replace('_', ' ').title()} Defaults" if section_header in content: # Find the section and add after the header - pattern = rf'({re.escape(section_header)}\s*\n\n)```yaml[\s\S]*?```' + pattern = rf"({re.escape(section_header)}\s*\n\n)```yaml[\s\S]*?```" if re.search(pattern, content): - content = re.sub(pattern, rf'\1{new_block}', content) + content = re.sub(pattern, rf"\1{new_block}", content) else: # Section exists but no yaml block, add it content = content.replace( - section_header, - f"{section_header}\n\n{new_block}" + section_header, f"{section_header}\n\n{new_block}" ) else: # No section found, append before the footer - footer_pattern = r'\n---\s*\n\*Last updated:' + footer_pattern = r"\n---\s*\n\*Last updated:" if re.search(footer_pattern, content): content = re.sub( footer_pattern, f"\n### {executor_type.replace('_', ' ').title()} Defaults\n\n{new_block}\n\n---\n\n*Last updated:", - content + content, ) else: # Just append at the end @@ -284,17 +288,18 @@ def update_defaults(self, executor_type: str, config: dict[str, Any]) -> None: # Update the last updated timestamp from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") content = re.sub( - r'\*Last updated:.*\*', - f'*Last updated: {timestamp}*', - content + r"\*Last updated:.*\*", f"*Last updated: {timestamp}*", content ) self._write_content(content) logger.info(f"Updated defaults for {executor_type}") - def merge_with_defaults(self, executor_type: str, user_config: dict[str, Any]) -> dict[str, Any]: + def merge_with_defaults( + self, executor_type: str, user_config: dict[str, Any] + ) -> dict[str, Any]: """Merge user configuration with stored defaults. User-provided values take precedence over defaults. diff --git a/mcp_servers/hummingbot_api/formatters/__init__.py b/mcp_servers/hummingbot_api/formatters/__init__.py index aee5bf34..1a83936f 100644 --- a/mcp_servers/hummingbot_api/formatters/__init__.py +++ b/mcp_servers/hummingbot_api/formatters/__init__.py @@ -28,19 +28,11 @@ truncate_string, ) -# Table builder for creating consistent tables -from .table_builder import ColumnDef, TableBuilder, create_simple_table - # Bot formatters -from .bots import format_active_bots_as_table, format_bot_logs_as_table, format_controller_state - -# Gateway formatters -from .gateway import ( - format_amm_result, - format_gateway_clmm_pool_result, - format_gateway_config_result, - format_gateway_container_result, - format_gateway_swap_result, +from .bots import ( + format_active_bots_as_table, + format_bot_logs_as_table, + format_controller_state, ) # Executor formatters @@ -54,6 +46,15 @@ format_positions_summary, ) +# Gateway formatters +from .gateway import ( + format_amm_result, + format_gateway_clmm_pool_result, + format_gateway_config_result, + format_gateway_container_result, + format_gateway_swap_result, +) + # Market data formatters from .market_data import ( format_candles_as_table, @@ -64,6 +65,9 @@ # Portfolio formatters from .portfolio import format_portfolio_as_table +# Table builder for creating consistent tables +from .table_builder import ColumnDef, TableBuilder, create_simple_table + # Trading formatters from .trading import format_orders_as_table, format_positions_as_table diff --git a/mcp_servers/hummingbot_api/formatters/base.py b/mcp_servers/hummingbot_api/formatters/base.py index 79f79747..7a898033 100644 --- a/mcp_servers/hummingbot_api/formatters/base.py +++ b/mcp_servers/hummingbot_api/formatters/base.py @@ -6,6 +6,7 @@ It also includes field accessor utilities for safely extracting values from dictionaries with fallback support. """ + from datetime import datetime, timezone from typing import Any @@ -75,7 +76,7 @@ def format_timestamp(ts: Any, format_str: str = "%m/%d %H:%M") -> str: dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) else: # Try parsing ISO format string - dt = datetime.fromisoformat(str(ts).replace('Z', '+00:00')) + dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) # Convert to UTC if timezone-aware if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc) @@ -190,7 +191,7 @@ def truncate_string(text: str, max_len: int = 80, suffix: str = "...") -> str: """ if len(text) <= max_len: return text - return text[:max_len - len(suffix)] + suffix + return text[: max_len - len(suffix)] + suffix def truncate_address(address: str, prefix_len: int = 8, suffix_len: int = 6) -> str: @@ -286,10 +287,7 @@ def get_timestamp_field(item: dict[str, Any], *keys: str) -> str: def get_truncated( - item: dict[str, Any], - key: str, - max_len: int, - default: str = "N/A" + item: dict[str, Any], key: str, max_len: int, default: str = "N/A" ) -> str: """ Get a string field and truncate it to a maximum length. @@ -320,7 +318,7 @@ def get_formatted_number( *keys: str, decimals: int = 2, compact: bool = True, - default: str = "N/A" + default: str = "N/A", ) -> str: """ Get a numeric field and format it. @@ -351,7 +349,7 @@ def get_formatted_currency( *keys: str, symbol: str = "$", decimals: int = 2, - default: str = "N/A" + default: str = "N/A", ) -> str: """ Get a numeric field and format it as currency. @@ -377,10 +375,7 @@ def get_formatted_currency( def get_formatted_percentage( - item: dict[str, Any], - *keys: str, - decimals: int = 2, - default: str = "N/A" + item: dict[str, Any], *keys: str, decimals: int = 2, default: str = "N/A" ) -> str: """ Get a decimal field and format it as percentage. diff --git a/mcp_servers/hummingbot_api/formatters/bots.py b/mcp_servers/hummingbot_api/formatters/bots.py index 07ccc602..e3b59f7f 100644 --- a/mcp_servers/hummingbot_api/formatters/bots.py +++ b/mcp_servers/hummingbot_api/formatters/bots.py @@ -3,6 +3,7 @@ This module provides table formatters for bot logs and active bot status. """ + from typing import Any from .base import ( @@ -121,13 +122,30 @@ def format_active_bots_as_table(bots_data: dict[str, Any]) -> str: # Bot with controllers for controller_name, controller_data in performance.items(): ctrl_state = format_controller_state(controller_data) - ctrl_perf = controller_data.get("performance", {}) if isinstance(controller_data, dict) else {} + ctrl_perf = ( + controller_data.get("performance", {}) + if isinstance(controller_data, dict) + else {} + ) - realized_pnl = format_number(get_field(ctrl_perf, "realized_pnl_quote", default=None), compact=False) - unrealized_pnl = format_number(get_field(ctrl_perf, "unrealized_pnl_quote", default=None), compact=False) - global_pnl = format_number(get_field(ctrl_perf, "global_pnl_quote", default=None), compact=False) - global_pnl_pct = format_percentage(get_field(ctrl_perf, "global_pnl_pct", default=None)) - volume = format_number(get_field(ctrl_perf, "volume_traded", default=None), compact=False) + realized_pnl = format_number( + get_field(ctrl_perf, "realized_pnl_quote", default=None), + compact=False, + ) + unrealized_pnl = format_number( + get_field(ctrl_perf, "unrealized_pnl_quote", default=None), + compact=False, + ) + global_pnl = format_number( + get_field(ctrl_perf, "global_pnl_quote", default=None), + compact=False, + ) + global_pnl_pct = format_percentage( + get_field(ctrl_perf, "global_pnl_pct", default=None) + ) + volume = format_number( + get_field(ctrl_perf, "volume_traded", default=None), compact=False + ) row = ( f"{bot_name} | " diff --git a/mcp_servers/hummingbot_api/formatters/executors.py b/mcp_servers/hummingbot_api/formatters/executors.py index ab3ab549..a4907fd7 100644 --- a/mcp_servers/hummingbot_api/formatters/executors.py +++ b/mcp_servers/hummingbot_api/formatters/executors.py @@ -4,6 +4,7 @@ This module provides table formatters for executor types, executor lists, positions held, and executor configuration schemas. """ + from typing import Any from .base import ( @@ -44,9 +45,15 @@ def format_executor_types_table(executor_types: list[dict[str, Any]]) -> str: rows = [] for exec_type in executor_types: name = str(get_field(exec_type, "name", default="unknown"))[:20] - description = truncate_string(str(get_field(exec_type, "description", default="")), max_len=40) - use_when = truncate_string(str(get_field(exec_type, "use_when", default="")), max_len=40) - avoid_when = truncate_string(str(get_field(exec_type, "avoid_when", default="")), max_len=40) + description = truncate_string( + str(get_field(exec_type, "description", default="")), max_len=40 + ) + use_when = truncate_string( + str(get_field(exec_type, "use_when", default="")), max_len=40 + ) + avoid_when = truncate_string( + str(get_field(exec_type, "avoid_when", default="")), max_len=40 + ) row = f"{name:20} | {description:40} | {use_when:40} | {avoid_when}" rows.append(row) @@ -54,7 +61,6 @@ def format_executor_types_table(executor_types: list[dict[str, Any]]) -> str: return f"{header}\n{separator}\n" + "\n".join(rows) - def format_executors_table(executors: list[dict[str, Any]]) -> str: """ Format a list of executors as a table. @@ -78,7 +84,9 @@ def format_executors_table(executors: list[dict[str, Any]]) -> str: rows = [] for executor in executors: exec_id = str(get_field(executor, "id", "executor_id", default="")) - exec_type = str(get_field(executor, "type", "executor_type", default="unknown"))[:15] + exec_type = str( + get_field(executor, "type", "executor_type", default="unknown") + )[:15] connector = str(get_field(executor, "connector_name", default=""))[:17] trading_pair = str(get_field(executor, "trading_pair", default=""))[:10] status = str(get_field(executor, "status", default="unknown")) @@ -92,10 +100,20 @@ def format_executors_table(executors: list[dict[str, Any]]) -> str: side = str(side)[:4] if side else "" # Volume is filled_amount_quote - volume = format_number(get_field(executor, "filled_amount_quote", default=None), decimals=2, compact=True) - pnl = format_number(get_field(executor, "net_pnl_quote", "pnl", default=None), decimals=2, compact=False) - - created = format_timestamp(get_field(executor, "timestamp", "created_at", default=0)) + volume = format_number( + get_field(executor, "filled_amount_quote", default=None), + decimals=2, + compact=True, + ) + pnl = format_number( + get_field(executor, "net_pnl_quote", "pnl", default=None), + decimals=2, + compact=False, + ) + + created = format_timestamp( + get_field(executor, "timestamp", "created_at", default=0) + ) row = f"{exec_id:44} | {exec_type:15} | {connector:17} | {trading_pair:10} | {status:10} | {close_type:20} | {side:4} | {volume:>11} | {pnl:>9} | {created}" rows.append(row) @@ -157,7 +175,9 @@ def format_executor_detail(executor: dict[str, Any]) -> str: entry_price = get_field(executor, "entry_price", default=None) if entry_price is not None and entry_price != "N/A": - output += f"Entry Price: {format_number(entry_price, decimals=6, compact=False)}\n" + output += ( + f"Entry Price: {format_number(entry_price, decimals=6, compact=False)}\n" + ) current_price = get_field(executor, "current_price", default=None) if current_price is not None and current_price != "N/A": @@ -201,7 +221,11 @@ def format_executor_detail(executor: dict[str, Any]) -> str: output += f"Created: {format_timestamp(created, '%Y-%m-%d %H:%M:%S')}\n" close_timestamp = get_field(executor, "close_timestamp", default=None) - if close_timestamp is not None and close_timestamp != "N/A" and close_timestamp != 0: + if ( + close_timestamp is not None + and close_timestamp != "N/A" + and close_timestamp != 0 + ): output += f"Closed: {format_timestamp(close_timestamp, '%Y-%m-%d %H:%M:%S')}\n" # Show full config if present (creation parameters) @@ -224,13 +248,17 @@ def format_executor_detail(executor: dict[str, Any]) -> str: output += f" end_price: {format_number(max(grid_levels), decimals=2, compact=False)}\n" output += f" num_levels: {len(grid_levels)}\n" sorted_levels = sorted(grid_levels) - spreads = [(sorted_levels[i+1] - sorted_levels[i]) / sorted_levels[i] - for i in range(len(sorted_levels) - 1)] + spreads = [ + (sorted_levels[i + 1] - sorted_levels[i]) / sorted_levels[i] + for i in range(len(sorted_levels) - 1) + ] avg_spread = sum(spreads) / len(spreads) if spreads else 0 output += f" avg_spread_between_levels: {format_percentage(avg_spread)}\n" if isinstance(grid_tp, list) and len(grid_tp) > 1: - tp_diffs = [abs(grid_tp[i] - grid_levels[i]) / grid_levels[i] - for i in range(min(len(grid_tp), len(grid_levels)))] + tp_diffs = [ + abs(grid_tp[i] - grid_levels[i]) / grid_levels[i] + for i in range(min(len(grid_tp), len(grid_levels))) + ] avg_tp = sum(tp_diffs) / len(tp_diffs) if tp_diffs else 0 output += f" avg_take_profit: {format_percentage(avg_tp)}\n" @@ -277,7 +305,9 @@ def format_positions_held_table(positions: list[dict[str, Any]]) -> str: amount_val = get_field(position, "net_amount_base", "amount", default=None) amount = format_number(amount_val, decimals=6, compact=False) # Handle both 'entry_price' and 'buy_breakeven_price' field names - entry_price_val = get_field(position, "buy_breakeven_price", "entry_price", default=None) + entry_price_val = get_field( + position, "buy_breakeven_price", "entry_price", default=None + ) entry_price = format_number(entry_price_val, decimals=4, compact=False) # Compute notional value (amount * entry_price) notional_val = None @@ -287,8 +317,16 @@ def format_positions_held_table(positions: list[dict[str, Any]]) -> str: except (ValueError, TypeError): pass notional = format_number(notional_val, decimals=2, compact=False) - current_price = format_number(get_field(position, "current_price", default=None), decimals=4, compact=False) - unrealized_pnl = format_number(get_field(position, "unrealized_pnl_quote", "unrealized_pnl", default=None), decimals=2, compact=False) + current_price = format_number( + get_field(position, "current_price", default=None), + decimals=4, + compact=False, + ) + unrealized_pnl = format_number( + get_field(position, "unrealized_pnl_quote", "unrealized_pnl", default=None), + decimals=2, + compact=False, + ) leverage = str(get_field(position, "leverage", default="1"))[:8] row = f"{connector:19} | {trading_pair:12} | {side:4} | {amount:>12} | {notional:>12} | {entry_price:>12} | {current_price:>13} | {unrealized_pnl:>14} | {leverage:>8}" @@ -340,7 +378,9 @@ def format_positions_summary(summary: dict[str, Any]) -> str: return output -def format_executor_schema_table(schema: dict[str, Any], defaults: dict[str, Any] | None = None) -> str: +def format_executor_schema_table( + schema: dict[str, Any], defaults: dict[str, Any] | None = None +) -> str: """ Format executor configuration schema as a table. @@ -379,7 +419,9 @@ def format_executor_schema_table(schema: dict[str, Any], defaults: dict[str, Any if isinstance(param_info, dict): param_type = param_info.get("type", param_info.get("anyOf", "unknown")) if isinstance(param_type, list): - param_type = "/".join(str(t.get("type", t)) for t in param_type if isinstance(t, dict)) + param_type = "/".join( + str(t.get("type", t)) for t in param_type if isinstance(t, dict) + ) param_type = str(param_type)[:17] required = "Yes" if param_name in required_fields else "No" @@ -390,9 +432,13 @@ def format_executor_schema_table(schema: dict[str, Any], defaults: dict[str, Any default_str = truncate_string(str(default_val), max_len=16) user_default = defaults.get(param_name, "") - user_default_str = truncate_string(str(user_default), max_len=16) if user_default else "-" + user_default_str = ( + truncate_string(str(user_default), max_len=16) if user_default else "-" + ) - description = truncate_string(str(param_info.get("description", "")), max_len=40) + description = truncate_string( + str(param_info.get("description", "")), max_len=40 + ) row = f"{param_name:28} | {param_type:17} | {required:8} | {default_str:16} | {user_default_str:16} | {description}" rows.append(row) diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 75c20420..230e3050 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -1,6 +1,7 @@ """ Gateway formatters for the Hummingbot MCP server. """ + from typing import Any @@ -11,8 +12,8 @@ def format_gateway_container_result(result: dict[str, Any]) -> str: if result_action == "get_status": status = result.get("status", {}) running = status.get("running", False) - container_id = status.get('container_id') - created_at = status.get('created_at') + container_id = status.get("container_id") + created_at = status.get("created_at") container_id_display = f"{container_id[:12]}..." if container_id else "None" created_at_display = created_at[:19] if created_at else "None" @@ -198,7 +199,12 @@ def format_amm_result(action: str, result: dict[str, Any]) -> str: if action in ("quote_swap", "quote_liquidity") and isinstance(payload, dict): return f"{header}\n{payload}" - if action in ("execute_swap", "add_liquidity", "remove_liquidity", "create_pool") and isinstance(payload, dict): + if action in ( + "execute_swap", + "add_liquidity", + "remove_liquidity", + "create_pool", + ) and isinstance(payload, dict): sig = payload.get("signature") extra = "" if action == "create_pool": diff --git a/mcp_servers/hummingbot_api/formatters/market_data.py b/mcp_servers/hummingbot_api/formatters/market_data.py index ce2bf3ed..ca2fc230 100644 --- a/mcp_servers/hummingbot_api/formatters/market_data.py +++ b/mcp_servers/hummingbot_api/formatters/market_data.py @@ -4,6 +4,7 @@ This module provides table formatters for market data including prices, OHLCV candles, and order book snapshots. """ + from typing import Any from .base import ( diff --git a/mcp_servers/hummingbot_api/formatters/portfolio.py b/mcp_servers/hummingbot_api/formatters/portfolio.py index d4afb3dd..34c6242f 100644 --- a/mcp_servers/hummingbot_api/formatters/portfolio.py +++ b/mcp_servers/hummingbot_api/formatters/portfolio.py @@ -3,6 +3,7 @@ This module provides table formatters for portfolio balances and holdings. """ + from typing import Any from .base import format_number, format_table_separator, get_field @@ -49,9 +50,17 @@ def format_portfolio_as_table(portfolio_data: dict[str, Any]) -> str: for balance in balances: token = str(get_field(balance, "token", default="N/A"))[:8] connector = connector_name[:17] - total = format_number(get_field(balance, "units", default=None), decimals=4, compact=True) - available = format_number(get_field(balance, "available_units", default=None), decimals=4, compact=True) - value_usd = format_number(get_field(balance, "value", default=None), decimals=2, compact=True) + total = format_number( + get_field(balance, "units", default=None), decimals=4, compact=True + ) + available = format_number( + get_field(balance, "available_units", default=None), + decimals=4, + compact=True, + ) + value_usd = format_number( + get_field(balance, "value", default=None), decimals=2, compact=True + ) row = f"{token:8} | {connector:17} | {total:12} | {available:12} | {value_usd}" rows.append(row) diff --git a/mcp_servers/hummingbot_api/formatters/table_builder.py b/mcp_servers/hummingbot_api/formatters/table_builder.py index af7eb7d3..49384304 100644 --- a/mcp_servers/hummingbot_api/formatters/table_builder.py +++ b/mcp_servers/hummingbot_api/formatters/table_builder.py @@ -4,6 +4,7 @@ This module provides the TableBuilder class and ColumnDef dataclass that standardize table creation, reducing code duplication across all formatters. """ + from dataclasses import dataclass, field from typing import Any, Callable @@ -100,7 +101,11 @@ def format_cell(self, item: dict[str, Any]) -> str: # Truncate if too long if len(value) > self.width: - value = value[:self.width - 3] + "..." if self.width > 3 else value[:self.width] + value = ( + value[: self.width - 3] + "..." + if self.width > 3 + else value[: self.width] + ) # Apply alignment if self.align == "right": @@ -143,7 +148,7 @@ def __init__( columns: list[ColumnDef], separator_char: str = "-", column_separator: str = " | ", - empty_message: str = "No data found." + empty_message: str = "No data found.", ): """ Initialize the table builder. @@ -171,7 +176,7 @@ def _build_header(self) -> str: for col in self.columns: header = col.name if len(header) > col.width: - header = header[:col.width] + header = header[: col.width] cells.append(header.ljust(col.width)) return self.column_separator.join(cells) @@ -180,7 +185,9 @@ def _build_row(self, item: dict[str, Any]) -> str: cells = [col.format_cell(item) for col in self.columns] return self.column_separator.join(cells) - def build(self, data: list[dict[str, Any]], empty_message: str | None = None) -> str: + def build( + self, data: list[dict[str, Any]], empty_message: str | None = None + ) -> str: """ Build the complete table string. @@ -201,10 +208,7 @@ def build(self, data: list[dict[str, Any]], empty_message: str | None = None) -> return f"{header}\n{separator}\n" + "\n".join(rows) def build_with_title( - self, - data: list[dict[str, Any]], - title: str, - empty_message: str | None = None + self, data: list[dict[str, Any]], title: str, empty_message: str | None = None ) -> str: """ Build table with a title above it. @@ -226,7 +230,7 @@ def build_with_title( def create_simple_table( data: list[dict[str, Any]], column_config: list[tuple[str, str, int]], - empty_message: str = "No data found." + empty_message: str = "No data found.", ) -> str: """ Convenience function to create a simple table without defining ColumnDef objects. @@ -248,6 +252,8 @@ def create_simple_table( Alice | 30 Bob | 25 """ - columns = [ColumnDef(name=name, key=key, width=width) for name, key, width in column_config] + columns = [ + ColumnDef(name=name, key=key, width=width) for name, key, width in column_config + ] builder = TableBuilder(columns, empty_message=empty_message) return builder.build(data) diff --git a/mcp_servers/hummingbot_api/formatters/trading.py b/mcp_servers/hummingbot_api/formatters/trading.py index 704cceca..ab0ddd68 100644 --- a/mcp_servers/hummingbot_api/formatters/trading.py +++ b/mcp_servers/hummingbot_api/formatters/trading.py @@ -4,6 +4,7 @@ This module provides table formatters for trading data including orders and positions. """ + from typing import Any from .base import format_number, get_field, get_timestamp_field @@ -26,7 +27,9 @@ def format_orders_as_table(orders: list[dict[str, Any]]) -> str: return "No orders found." def format_time(item: dict) -> str: - return get_timestamp_field(item, "created_at", "creation_timestamp", "timestamp") + return get_timestamp_field( + item, "created_at", "creation_timestamp", "timestamp" + ) def format_pair(item: dict) -> str: return str(get_field(item, "trading_pair", default="N/A"))[:12] @@ -38,13 +41,18 @@ def format_type(item: dict) -> str: return str(get_field(item, "order_type", "type", default="N/A"))[:6] def format_amount(item: dict) -> str: - return format_number(get_field(item, "amount", "order_size", default=None), compact=False) + return format_number( + get_field(item, "amount", "order_size", default=None), compact=False + ) def format_price(item: dict) -> str: return format_number(get_field(item, "price", default=None), compact=False) def format_filled(item: dict) -> str: - return format_number(get_field(item, "filled_amount", "executed_amount_base", default=None), compact=False) + return format_number( + get_field(item, "filled_amount", "executed_amount_base", default=None), + compact=False, + ) def format_status(item: dict) -> str: return str(get_field(item, "status", default="N/A"))[:8] @@ -106,16 +114,24 @@ def format_side(item: dict) -> str: return str(get_field(item, "position_side", "side", default="N/A"))[:5] def format_amount(item: dict) -> str: - return format_number(get_field(item, "amount", "position_size", default=None), compact=False) + return format_number( + get_field(item, "amount", "position_size", default=None), compact=False + ) def format_entry(item: dict) -> str: - return format_number(get_field(item, "entry_price", default=None), compact=False) + return format_number( + get_field(item, "entry_price", default=None), compact=False + ) def format_current(item: dict) -> str: - return format_number(get_field(item, "current_price", "mark_price", default=None), compact=False) + return format_number( + get_field(item, "current_price", "mark_price", default=None), compact=False + ) def format_pnl(item: dict) -> str: - return format_number(get_field(item, "unrealized_pnl", default=None), compact=False) + return format_number( + get_field(item, "unrealized_pnl", default=None), compact=False + ) def format_leverage(item: dict) -> str: return str(get_field(item, "leverage", default="N/A")) diff --git a/mcp_servers/hummingbot_api/hummingbot_client.py b/mcp_servers/hummingbot_api/hummingbot_client.py index a371c6d5..3c04f4ba 100644 --- a/mcp_servers/hummingbot_api/hummingbot_client.py +++ b/mcp_servers/hummingbot_api/hummingbot_client.py @@ -56,7 +56,9 @@ async def initialize(self, force: bool = False) -> HummingbotAPIClient: await self._client.accounts.list_accounts() self._initialized = True - logger.info(f"Successfully connected to Hummingbot API at {settings.api_url}") + logger.info( + f"Successfully connected to Hummingbot API at {settings.api_url}" + ) return self._client except Exception as e: @@ -65,7 +67,11 @@ async def initialize(self, force: bool = False) -> HummingbotAPIClient: logger.warning(f"Connection attempt {attempt + 1} failed: {e}") # Don't retry on authentication errors - if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if ( + "401" in error_str + or "unauthorized" in error_str + or "authentication" in error_str + ): self._failed_url = settings.api_url self._last_error = MaxConnectionsAttemptError( f"❌ Authentication failed when connecting to Hummingbot API at {settings.api_url}\n\n" @@ -87,7 +93,12 @@ async def initialize(self, force: bool = False) -> HummingbotAPIClient: self._failed_url = settings.api_url error_str = str(last_error).lower() if last_error else "" - if "connection" in error_str or "refused" in error_str or "unreachable" in error_str or "timeout" in error_str: + if ( + "connection" in error_str + or "refused" in error_str + or "unreachable" in error_str + or "timeout" in error_str + ): error_message = ( f"❌ Cannot reach Hummingbot API at {settings.api_url}\n\n" f"The API server is not responding. This usually means:\n" @@ -101,7 +112,10 @@ async def initialize(self, force: bool = False) -> HummingbotAPIClient: ) # Add Docker networking warning for localhost URLs - if "localhost" in settings.api_url and os.getenv("DOCKER_CONTAINER") == "true": + if ( + "localhost" in settings.api_url + and os.getenv("DOCKER_CONTAINER") == "true" + ): system = platform.system() if system in ["Darwin", "Windows"]: error_message += ( diff --git a/mcp_servers/hummingbot_api/middleware.py b/mcp_servers/hummingbot_api/middleware.py index ff8be8c8..8fe5726a 100644 --- a/mcp_servers/hummingbot_api/middleware.py +++ b/mcp_servers/hummingbot_api/middleware.py @@ -1,11 +1,15 @@ """ Middleware decorators for common tool patterns. """ + import functools import logging from typing import Any, Callable, Coroutine, TypeVar -from mcp_servers.hummingbot_api.exceptions import MaxConnectionsAttemptError as HBConnectionError, ToolError +from mcp_servers.hummingbot_api.exceptions import ( + MaxConnectionsAttemptError as HBConnectionError, +) +from mcp_servers.hummingbot_api.exceptions import ToolError logger = logging.getLogger("hummingbot-mcp") @@ -17,7 +21,9 @@ def handle_errors( action_name: str, error_suffix: str = "", -) -> Callable[[Callable[..., Coroutine[Any, Any, T]]], Callable[..., Coroutine[Any, Any, T]]]: +) -> Callable[ + [Callable[..., Coroutine[Any, Any, T]]], Callable[..., Coroutine[Any, Any, T]] +]: """ Decorator for standardized error handling in tool functions. @@ -28,7 +34,10 @@ def handle_errors( action_name: Description of the action for error messages (e.g., "get prices") error_suffix: Optional string appended to error messages (e.g., GATEWAY_LOG_HINT) """ - def decorator(func: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., Coroutine[Any, Any, T]]: + + def decorator( + func: Callable[..., Coroutine[Any, Any, T]] + ) -> Callable[..., Coroutine[Any, Any, T]]: @functools.wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> T: try: @@ -40,5 +49,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> T: except Exception as e: logger.error(f"{action_name} failed: {str(e)}", exc_info=True) raise ToolError(f"Failed to {action_name}: {str(e)}{error_suffix}") + return wrapper + return decorator diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index edcb26aa..917dddba 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -598,7 +598,10 @@ class AMMRequest(BaseModel): "create_pool", ] | None - ) = Field(default=None, description="AMM action. Leave empty to load the AMM guide + param matrix.") + ) = Field( + default=None, + description="AMM action. Leave empty to load the AMM guide + param matrix.", + ) connector: str | None = Field( default=None, @@ -608,7 +611,10 @@ class AMMRequest(BaseModel): default=None, description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'base-mainnet'", ) - wallet_address: str | None = Field(default=None, description="Wallet address (optional, uses default if not provided)") + wallet_address: str | None = Field( + default=None, + description="Wallet address (optional, uses default if not provided)", + ) pool_address: str | None = Field(default=None, description="Pool contract address") position_address: str | None = Field( default=None, @@ -616,20 +622,55 @@ class AMMRequest(BaseModel): ) # Swap params - base_token: str | None = Field(default=None, description="Base token symbol or address (swap direction / pool base)") - quote_token: str | None = Field(default=None, description="Quote token symbol or address (pool quote, for create_pool)") - amount: str | None = Field(default=None, description="Swap amount (as string; parsed to Decimal)") - side: Literal["BUY", "SELL"] | None = Field(default=None, description="Swap direction") - slippage_pct: str | None = Field(default=None, description="Maximum slippage percentage (as string)") + base_token: str | None = Field( + default=None, + description="Base token symbol or address (swap direction / pool base)", + ) + quote_token: str | None = Field( + default=None, + description="Quote token symbol or address (pool quote, for create_pool)", + ) + amount: str | None = Field( + default=None, description="Swap amount (as string; parsed to Decimal)" + ) + side: Literal["BUY", "SELL"] | None = Field( + default=None, description="Swap direction" + ) + slippage_pct: str | None = Field( + default=None, description="Maximum slippage percentage (as string)" + ) # Liquidity params - base_token_amount: str | None = Field(default=None, description="Base token amount (add_liquidity / quote_liquidity / create_pool)") - quote_token_amount: str | None = Field(default=None, description="Quote token amount (add_liquidity / quote_liquidity / create_pool)") - percentage_to_remove: str | None = Field(default=None, description="Percentage of liquidity to remove, 0-100 (remove_liquidity)") + base_token_amount: str | None = Field( + default=None, + description="Base token amount (add_liquidity / quote_liquidity / create_pool)", + ) + quote_token_amount: str | None = Field( + default=None, + description="Quote token amount (add_liquidity / quote_liquidity / create_pool)", + ) + percentage_to_remove: str | None = Field( + default=None, + description="Percentage of liquidity to remove, 0-100 (remove_liquidity)", + ) # create_pool params - initial_price: str | None = Field(default=None, description="Initial price as quote per base (create_pool; overrides quote_token_amount)") - config_address: str | None = Field(default=None, description="Meteora DAMM v2 config account address (required for meteora create_pool)") - fee_config_index: int | None = Field(default=None, description="Raydium CPMM fee config index (optional, create_pool)") - gas_price: str | None = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional, create_pool)") - max_gas: int | None = Field(default=None, description="Uniswap (EVM) max gas limit (optional, create_pool)") + initial_price: str | None = Field( + default=None, + description="Initial price as quote per base (create_pool; overrides quote_token_amount)", + ) + config_address: str | None = Field( + default=None, + description="Meteora DAMM v2 config account address (required for meteora create_pool)", + ) + fee_config_index: int | None = Field( + default=None, + description="Raydium CPMM fee config index (optional, create_pool)", + ) + gas_price: str | None = Field( + default=None, + description="Uniswap (EVM) gas price in gwei (optional, create_pool)", + ) + max_gas: int | None = Field( + default=None, description="Uniswap (EVM) max gas limit (optional, create_pool)" + ) diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index e9f1f077..3c71eea7 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -46,9 +46,7 @@ from mcp_servers.hummingbot_api.tools.gateway import ( manage_gateway_container as manage_gateway_container_impl, ) -from mcp_servers.hummingbot_api.tools.gateway_amm import ( - manage_amm_impl, -) +from mcp_servers.hummingbot_api.tools.gateway_amm import manage_amm_impl from mcp_servers.hummingbot_api.tools.gateway_clmm import ( explore_gateway_clmm_pools as explore_gateway_clmm_pools_impl, ) diff --git a/mcp_servers/hummingbot_api/tools/bot_management.py b/mcp_servers/hummingbot_api/tools/bot_management.py index 4606c802..b2ff3a7b 100644 --- a/mcp_servers/hummingbot_api/tools/bot_management.py +++ b/mcp_servers/hummingbot_api/tools/bot_management.py @@ -4,13 +4,19 @@ This module provides the core business logic for managing bots, including status retrieval, log management, and execution control. """ + import asyncio from typing import Any, Literal -from mcp_servers.hummingbot_api.formatters import format_active_bots_as_table, format_bot_logs_as_table +from mcp_servers.hummingbot_api.formatters import ( + format_active_bots_as_table, + format_bot_logs_as_table, +) -async def _get_controller_configs_map(client: Any, bot_name: str) -> dict[str, dict[str, Any]]: +async def _get_controller_configs_map( + client: Any, bot_name: str +) -> dict[str, dict[str, Any]]: """ Fetch a bot's controller configs keyed by both config id and config file name. @@ -58,7 +64,9 @@ async def get_active_bots_status(client: Any) -> dict[str, Any]: bots_table = format_active_bots_as_table(active_bots) # Count total bots - total_bots = len(active_bots.get("data", {})) if isinstance(active_bots, dict) else 0 + total_bots = ( + len(active_bots.get("data", {})) if isinstance(active_bots, dict) else 0 + ) return { "active_bots": active_bots, @@ -97,7 +105,9 @@ async def _attach_kill_switches(client: Any, bots: dict[str, Any]) -> None: continue config = configs.get(str(controller_id)) if config is not None: - controller_data["kill_switch"] = bool(config.get("manual_kill_switch", False)) + controller_data["kill_switch"] = bool( + config.get("manual_kill_switch", False) + ) async def get_bot_logs( @@ -150,7 +160,10 @@ async def get_bot_logs( if log_type in ["error", "all"] and "error_logs" in bot_data: error_logs = bot_data["error_logs"] for log_entry in error_logs: - if search_term is None or search_term.lower() in log_entry.get("msg", "").lower(): + if ( + search_term is None + or search_term.lower() in log_entry.get("msg", "").lower() + ): log_entry["log_category"] = "error" logs.append(log_entry) @@ -158,7 +171,10 @@ async def get_bot_logs( if log_type in ["general", "all"] and "general_logs" in bot_data: general_logs = bot_data["general_logs"] for log_entry in general_logs: - if search_term is None or search_term.lower() in log_entry.get("msg", "").lower(): + if ( + search_term is None + or search_term.lower() in log_entry.get("msg", "").lower() + ): log_entry["log_category"] = "general" logs.append(log_entry) @@ -281,9 +297,13 @@ async def _set_kill_switches( updated_configs = {} for name in succeeded: config = updated_configs.get(name) or updated_configs.get(targets[name]) - confirmed[name] = bool(config.get("manual_kill_switch", False)) if config else None + confirmed[name] = ( + bool(config.get("manual_kill_switch", False)) if config else None + ) - unconfirmed = [name for name, value in confirmed.items() if value is not kill_switch] + unconfirmed = [ + name for name, value in confirmed.items() if value is not kill_switch + ] verified = [name for name in succeeded if name not in unconfirmed] message = ( @@ -364,10 +384,14 @@ async def update_bot_controller_config( config_controller_name = config_data.get("controller_name") if not config_controller_type or not config_controller_name: - raise ValueError("config_data must include 'controller_type' and 'controller_name'") + raise ValueError( + "config_data must include 'controller_type' and 'controller_name'" + ) # Validate config first - await client.controllers.validate_controller_config(config_controller_type, config_controller_name, config_data) + await client.controllers.validate_controller_config( + config_controller_type, config_controller_name, config_data + ) if not confirm_override: current_configs = await client.controllers.get_bot_controller_configs(bot_name) @@ -379,12 +403,16 @@ async def update_bot_controller_config( "config_name": config_name, "bot_name": bot_name, "current_config": config, - "message": (f"Config '{config_name}' already exists in bot '{bot_name}' with data: {config}. " - "Set confirm_override=True to update it."), + "message": ( + f"Config '{config_name}' already exists in bot '{bot_name}' with data: {config}. " + "Set confirm_override=True to update it." + ), } else: clean_data = {k: v for k, v in config_data.items() if not k.startswith("_")} - update_op = await client.controllers.update_bot_controller_config(bot_name, config_name, clean_data) + update_op = await client.controllers.update_bot_controller_config( + bot_name, config_name, clean_data + ) return { "action": "update_config", "exists": False, @@ -398,7 +426,9 @@ async def update_bot_controller_config( if "id" not in config_data or config_data["id"] != config_name: config_data["id"] = config_name clean_data = {k: v for k, v in config_data.items() if not k.startswith("_")} - update_op = await client.controllers.update_bot_controller_config(bot_name, config_name, clean_data) + update_op = await client.controllers.update_bot_controller_config( + bot_name, config_name, clean_data + ) return { "action": "update_config", "exists": True, diff --git a/mcp_servers/hummingbot_api/tools/controllers.py b/mcp_servers/hummingbot_api/tools/controllers.py index 3b11c6c0..903408b8 100644 --- a/mcp_servers/hummingbot_api/tools/controllers.py +++ b/mcp_servers/hummingbot_api/tools/controllers.py @@ -4,10 +4,17 @@ This module provides the core business logic for managing controllers and their configurations, including exploration, modification, and bot deployment. """ + from typing import Any, Literal # Internal/auto-managed fields that should be skipped during schema validation -_SKIP_FIELDS = {"id", "controller_name", "controller_type", "candles_config", "initial_positions"} +_SKIP_FIELDS = { + "id", + "controller_name", + "controller_type", + "candles_config", + "initial_positions", +} def _validate_config_against_template( @@ -47,7 +54,8 @@ def _validate_config_against_template( if missing_fields: raise ValueError( "Config validation failed against controller template schema.\n\n" - "Missing required fields (no default value in schema):\n" + "\n".join(missing_fields) + "Missing required fields (no default value in schema):\n" + + "\n".join(missing_fields) + "\n\nUse manage_controllers(action='describe', controller_name='" + str(config_data.get("controller_name", "...")) + "') to see all available parameters and their defaults." @@ -66,7 +74,9 @@ async def manage_controllers( client: Any, action: Literal["list", "describe", "upsert", "delete"], target: Literal["controller", "config"] | None = None, - controller_type: Literal["directional_trading", "market_making", "generic"] | None = None, + controller_type: ( + Literal["directional_trading", "market_making", "generic"] | None + ) = None, controller_name: str | None = None, controller_code: str | None = None, config_name: str | None = None, @@ -93,7 +103,9 @@ async def manage_controllers( ) elif action in ("upsert", "delete"): if not target: - raise ValueError("'target' parameter ('controller' or 'config') is required for upsert/delete actions") + raise ValueError( + "'target' parameter ('controller' or 'config') is required for upsert/delete actions" + ) return await modify_controllers( client=client, action=action, @@ -106,13 +118,17 @@ async def manage_controllers( confirm_override=confirm_override, ) else: - raise ValueError(f"Invalid action '{action}'. Use 'list', 'describe', 'upsert', or 'delete'.") + raise ValueError( + f"Invalid action '{action}'. Use 'list', 'describe', 'upsert', or 'delete'." + ) async def explore_controllers( client: Any, action: Literal["list", "describe"], - controller_type: Literal["directional_trading", "market_making", "generic"] | None = None, + controller_type: ( + Literal["directional_trading", "market_making", "generic"] | None + ) = None, controller_name: str | None = None, config_name: str | None = None, include_code: bool = False, @@ -142,7 +158,9 @@ async def explore_controllers( continue result += f"Controller Type: {c_type}\n" for controller in controller_list: - controller_configs = [c for c in configs if c.get('controller_name') == controller] + controller_configs = [ + c for c in configs if c.get("controller_name") == controller + ] result += f"- {controller} ({len(controller_configs)} configs)\n" if len(controller_configs) > 0: for config in controller_configs: @@ -195,15 +213,21 @@ async def explore_controllers( } # Get config template (lightweight — just parameter schema) - controller_configs = [c.get("id") for c in configs if c.get('controller_name') == controller_name] - template = await client.controllers.get_controller_config_template(found_controller_type, controller_name) + controller_configs = [ + c.get("id") for c in configs if c.get("controller_name") == controller_name + ] + template = await client.controllers.get_controller_config_template( + found_controller_type, controller_name + ) result += f"Controller: {controller_name} ({found_controller_type})\n\n" # Only fetch and include full source code when explicitly requested controller_code_content = None if include_code: - controller_code_content = await client.controllers.get_controller(found_controller_type, controller_name) + controller_code_content = await client.controllers.get_controller( + found_controller_type, controller_name + ) result += f"Controller Code:\n{controller_code_content}\n\n" # Format config template parameters as table @@ -212,15 +236,25 @@ async def explore_controllers( result += "-" * 80 + "\n" for param_name, param_info in template.items(): - if param_name in ['id', 'controller_name', 'controller_type', 'candles_config', 'initial_positions']: + if param_name in [ + "id", + "controller_name", + "controller_type", + "candles_config", + "initial_positions", + ]: continue # Skip internal fields - param_type = str(param_info.get('type', 'unknown')) + param_type = str(param_info.get("type", "unknown")) # Simplify type names - param_type = param_type.replace("", "").replace("decimal.Decimal", "Decimal") + param_type = ( + param_type.replace("", "") + .replace("decimal.Decimal", "Decimal") + ) param_type = param_type.replace("typing.", "").split(".")[-1][:15] - default = str(param_info.get('default', 'None')) + default = str(param_info.get("default", "None")) if len(default) > 30: default = default[:27] + "..." @@ -231,14 +265,20 @@ async def explore_controllers( # Format configs list result += f"Total Configs: {len(controller_configs)}\n" if len(controller_configs) <= 10: - result += "Configs:\n" + "\n".join(f" - {c}" for c in controller_configs if c) + "\n" + result += ( + "Configs:\n" + + "\n".join(f" - {c}" for c in controller_configs if c) + + "\n" + ) else: result += f"Configs (showing first 10 of {len(controller_configs)}):\n" result += "\n".join(f" - {c}" for c in controller_configs[:10] if c) + "\n" result += f" ... and {len(controller_configs) - 10} more\n" if not include_code: - result += "\nTip: Set include_code=True to see the full controller source code.\n" + result += ( + "\nTip: Set include_code=True to see the full controller source code.\n" + ) return_data = { "action": "describe", @@ -265,7 +305,9 @@ async def modify_controllers( client: Any, action: Literal["upsert", "delete"], target: Literal["controller", "config"], - controller_type: Literal["directional_trading", "market_making", "generic"] | None = None, + controller_type: ( + Literal["directional_trading", "market_making", "generic"] | None + ) = None, controller_name: str | None = None, controller_code: str | None = None, config_name: str | None = None, @@ -297,14 +339,18 @@ async def modify_controllers( if target == "controller": if action == "upsert": if not controller_type or not controller_name or not controller_code: - raise ValueError("controller_type, controller_name, and controller_code are required for controller upsert") + raise ValueError( + "controller_type, controller_name, and controller_code are required for controller upsert" + ) # Check if controller exists controllers = await client.controllers.list_controllers() exists = controller_name in controllers.get(controller_type, []) if exists and not confirm_override: - existing_code = await client.controllers.get_controller(controller_type, controller_name) + existing_code = await client.controllers.get_controller( + controller_type, controller_name + ) return { "action": "upsert", "target": "controller", @@ -312,15 +358,19 @@ async def modify_controllers( "controller_name": controller_name, "controller_type": controller_type, "current_code": existing_code, - "message": (f"Controller '{controller_name}' already exists and this is the current code: {existing_code}. " - f"Set confirm_override=True to update it."), + "message": ( + f"Controller '{controller_name}' already exists and this is the current code: {existing_code}. " + f"Set confirm_override=True to update it." + ), } # POST /controllers/{type}/{name} expects a Controller body -- an OBJECT with a # "content" field -- not a bare source string. Passing the string through made # FastAPI reject the body with 422 for every controller upload. result = await client.controllers.create_or_update_controller( - controller_type, controller_name, {"content": controller_code, "type": controller_type} + controller_type, + controller_name, + {"content": controller_code, "type": controller_type}, ) return { @@ -335,9 +385,13 @@ async def modify_controllers( elif action == "delete": if not controller_type or not controller_name: - raise ValueError("controller_type and controller_name are required for controller delete") + raise ValueError( + "controller_type and controller_name are required for controller delete" + ) - result = await client.controllers.delete_controller(controller_type, controller_name) + result = await client.controllers.delete_controller( + controller_type, controller_name + ) return { "action": "delete", @@ -351,14 +405,18 @@ async def modify_controllers( elif target == "config": if action == "upsert": if not config_name or not config_data: - raise ValueError("config_name and config_data are required for config upsert") + raise ValueError( + "config_name and config_data are required for config upsert" + ) # Extract controller_type and controller_name from config_data config_controller_type = config_data.get("controller_type") config_controller_name = config_data.get("controller_name") if not config_controller_type or not config_controller_name: - raise ValueError("config_data must include 'controller_type' and 'controller_name'") + raise ValueError( + "config_data must include 'controller_type' and 'controller_name'" + ) # Always set the config id to match the config name (file name), so the # backend gets a complete config for validation and storage. @@ -372,26 +430,34 @@ async def modify_controllers( _validate_config_against_template(config_data, template) # Validate config with backend - await client.controllers.validate_controller_config(config_controller_type, config_controller_name, config_data) + await client.controllers.validate_controller_config( + config_controller_type, config_controller_name, config_data + ) controller_configs = await client.controllers.list_controller_configs() exists = config_name in [c.get("id") for c in controller_configs] if exists and not confirm_override: - existing_config = await client.controllers.get_controller_config(config_name) + existing_config = await client.controllers.get_controller_config( + config_name + ) return { "action": "upsert", "target": "config", "exists": True, "config_name": config_name, "current_config": existing_config, - "message": (f"Config '{config_name}' already exists with data: {existing_config}. " - "Set confirm_override=True to update it."), + "message": ( + f"Config '{config_name}' already exists with data: {existing_config}. " + "Set confirm_override=True to update it." + ), } # Strip internal fields like _config_name that cause Pydantic validation errors clean_data = {k: v for k, v in config_data.items() if not k.startswith("_")} - result = await client.controllers.create_or_update_controller_config(config_name, clean_data) + result = await client.controllers.create_or_update_controller_config( + config_name, clean_data + ) return { "action": "upsert", "target": "config", diff --git a/mcp_servers/hummingbot_api/tools/executors.py b/mcp_servers/hummingbot_api/tools/executors.py index fe99b3a4..fc43d135 100644 --- a/mcp_servers/hummingbot_api/tools/executors.py +++ b/mcp_servers/hummingbot_api/tools/executors.py @@ -4,6 +4,7 @@ This module provides business logic for managing trading executors including creation, viewing, stopping, and position management with progressive disclosure. """ + import logging from typing import Any @@ -23,7 +24,9 @@ _INTERNAL_FIELDS = {"type", "executor_type", "id"} -def validate_executor_config(config: dict[str, Any], schema: dict[str, Any]) -> list[str]: +def validate_executor_config( + config: dict[str, Any], schema: dict[str, Any] +) -> list[str]: """Validate config keys against the backend schema properties. Returns a list of error strings. An empty list means the config is valid. @@ -33,7 +36,9 @@ def validate_executor_config(config: dict[str, Any], schema: dict[str, Any]) -> return errors -def _validate_level(config: dict[str, Any], schema: dict[str, Any], path: str, errors: list[str]) -> None: +def _validate_level( + config: dict[str, Any], schema: dict[str, Any], path: str, errors: list[str] +) -> None: """Recursively validate config keys against schema properties.""" properties = schema.get("properties", {}) if not properties: @@ -47,15 +52,23 @@ def _validate_level(config: dict[str, Any], schema: dict[str, Any], path: str, e if key not in allowed: field_list = ", ".join(sorted(allowed - _INTERNAL_FIELDS)) location = f" inside '{path}'" if path else "" - errors.append(f"Unknown field '{key}'{location}. Allowed fields: {field_list}") + errors.append( + f"Unknown field '{key}'{location}. Allowed fields: {field_list}" + ) continue # Recurse into nested objects prop_schema = properties[key] - if isinstance(prop_schema, dict) and isinstance(config[key], dict) and "properties" in prop_schema: + if ( + isinstance(prop_schema, dict) + and isinstance(config[key], dict) + and "properties" in prop_schema + ): _validate_level(config[key], prop_schema, key, errors) -async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict[str, Any]: +async def manage_executors( + client: Any, request: ManageExecutorsRequest +) -> dict[str, Any]: """ Manage executors with progressive disclosure. @@ -90,7 +103,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict elif flow_stage == "show_schema": # Stage 2: Show config schema with user defaults try: - schema = await client.executors.get_executor_config_schema(request.executor_type) + schema = await client.executors.get_executor_config_schema( + request.executor_type + ) except Exception as e: return { "action": "show_schema", @@ -114,7 +129,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict formatted += f"\n\nYour saved defaults for {request.executor_type}:\n" for key, value in user_defaults.items(): formatted += f" {key}: {value}\n" - formatted += f"\nPreferences file: {executor_preferences.get_preferences_path()}" + formatted += ( + f"\nPreferences file: {executor_preferences.get_preferences_path()}" + ) return { "action": "show_schema", @@ -128,7 +145,11 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict elif flow_stage == "create": # Stage 3: Create executor - executor_type = request.executor_type or request.executor_config.get("type") or request.executor_config.get("executor_type") + executor_type = ( + request.executor_type + or request.executor_config.get("type") + or request.executor_config.get("executor_type") + ) if not executor_type: return { @@ -138,7 +159,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict } # Merge with defaults - merged_config = executor_preferences.merge_with_defaults(executor_type, request.executor_config) + merged_config = executor_preferences.merge_with_defaults( + executor_type, request.executor_config + ) # Ensure type is set in config if "type" not in merged_config and "executor_type" not in merged_config: @@ -164,14 +187,19 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict account = request.account_name or "master_account" # Check both top-level param and executor_config (agents sometimes put it in the wrong place) - controller_id = request.controller_id or merged_config.pop("controller_id", None) or "main" + controller_id = ( + request.controller_id or merged_config.pop("controller_id", None) or "main" + ) import logging as _logging + _logging.getLogger(__name__).info( "create_executor: controller_id=%r (request=%r, config_had=%r), type=%s, account=%s", - controller_id, request.controller_id, + controller_id, + request.controller_id, "controller_id" in (request.executor_config or {}), - executor_type, account, + executor_type, + account, ) try: @@ -183,7 +211,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict # Save as default if requested if request.save_as_default: - executor_preferences.update_defaults(executor_type, request.executor_config) + executor_preferences.update_defaults( + executor_type, request.executor_config + ) executor_id = result.get("executor_id") or result.get("id") @@ -238,7 +268,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict controller_ids=request.controller_ids, ) - executors = result.get("data", result) if isinstance(result, dict) else result + executors = ( + result.get("data", result) if isinstance(result, dict) else result + ) if not isinstance(executors, list): executors = [executors] if executors else [] @@ -253,7 +285,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict "action": "search", "executors": executors, "count": len(executors), - "cursor": result.get("next_cursor") if isinstance(result, dict) else None, + "cursor": ( + result.get("next_cursor") if isinstance(result, dict) else None + ), "formatted_output": formatted, } @@ -345,7 +379,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict # Stage 8: Get saved preferences (returns raw markdown content) raw_content = executor_preferences.get_raw_content() - formatted = f"Preferences file: {executor_preferences.get_preferences_path()}\n\n" + formatted = ( + f"Preferences file: {executor_preferences.get_preferences_path()}\n\n" + ) formatted += raw_content return { @@ -377,10 +413,14 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict formatted = "Preferences documentation updated to latest version.\n\n" if preserved_count > 0: preserved_names = [k for k, v in preserved.items() if v] - formatted += f"Preserved {preserved_count} config(s): {', '.join(preserved_names)}\n" + formatted += ( + f"Preserved {preserved_count} config(s): {', '.join(preserved_names)}\n" + ) else: formatted += "No existing configs to preserve.\n" - formatted += f"\nPreferences file: {executor_preferences.get_preferences_path()}" + formatted += ( + f"\nPreferences file: {executor_preferences.get_preferences_path()}" + ) return { "action": "reset_preferences", @@ -413,7 +453,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict positions = [result] if not isinstance(result, list) else result formatted += format_positions_held_table(positions) else: - formatted += "No position found for this connector/pair combination." + formatted += ( + "No position found for this connector/pair combination." + ) return { "action": "positions_summary", @@ -428,13 +470,17 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict controller_id=request.controller_id, ) - positions = result.get("positions", result) if isinstance(result, dict) else result + positions = ( + result.get("positions", result) if isinstance(result, dict) else result + ) if not isinstance(positions, list): positions = [positions] if positions else [] formatted = f"Positions Held Summary\n\n" - if isinstance(result, dict) and any(k in result for k in ["total_positions", "total_value", "by_connector"]): + if isinstance(result, dict) and any( + k in result for k in ["total_positions", "total_value", "by_connector"] + ): formatted += format_positions_summary(result) if positions: formatted += "\n\nPositions Detail:\n" @@ -445,7 +491,9 @@ async def manage_executors(client: Any, request: ManageExecutorsRequest) -> dict return { "action": "positions_summary", "positions": positions, - "summary": result if isinstance(result, dict) else {"positions": positions}, + "summary": ( + result if isinstance(result, dict) else {"positions": positions} + ), "formatted_output": formatted, } diff --git a/mcp_servers/hummingbot_api/tools/gateway.py b/mcp_servers/hummingbot_api/tools/gateway.py index c4ecdf17..716444fc 100644 --- a/mcp_servers/hummingbot_api/tools/gateway.py +++ b/mcp_servers/hummingbot_api/tools/gateway.py @@ -1,16 +1,22 @@ """ Gateway management tools for Hummingbot MCP Server """ + import logging from typing import Any from mcp_servers.hummingbot_api.exceptions import ToolError -from mcp_servers.hummingbot_api.schemas import GatewayConfigRequest, GatewayContainerRequest +from mcp_servers.hummingbot_api.schemas import ( + GatewayConfigRequest, + GatewayContainerRequest, +) logger = logging.getLogger("hummingbot-mcp") -async def manage_gateway_container(client: Any, request: GatewayContainerRequest) -> dict[str, Any]: +async def manage_gateway_container( + client: Any, request: GatewayContainerRequest +) -> dict[str, Any]: """Manage Gateway container lifecycle operations. Supports: @@ -22,10 +28,7 @@ async def manage_gateway_container(client: Any, request: GatewayContainerRequest """ if request.action == "get_status": result = await client.gateway.get_status() - return { - "action": "get_status", - "status": result - } + return {"action": "get_status", "status": result} elif request.action == "start": if not request.config: @@ -38,7 +41,7 @@ async def manage_gateway_container(client: Any, request: GatewayContainerRequest return { "action": "start", "message": "Gateway started successfully", - "result": result + "result": result, } elif request.action == "stop": @@ -46,7 +49,7 @@ async def manage_gateway_container(client: Any, request: GatewayContainerRequest return { "action": "stop", "message": "Gateway stopped successfully", - "result": result + "result": result, } elif request.action == "restart": @@ -55,22 +58,20 @@ async def manage_gateway_container(client: Any, request: GatewayContainerRequest "action": "restart", "message": "Gateway restarted successfully", "result": result, - "config_updated": request.config is not None + "config_updated": request.config is not None, } elif request.action == "get_logs": result = await client.gateway.get_logs(tail=request.tail or 100) - return { - "action": "get_logs", - "tail": request.tail or 100, - "logs": result - } + return {"action": "get_logs", "tail": request.tail or 100, "logs": result} else: raise ToolError(f"Unknown action: {request.action}") -async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> dict[str, Any]: +async def manage_gateway_config( + client: Any, request: GatewayConfigRequest +) -> dict[str, Any]: """Manage Gateway configuration for chains, networks, tokens, connectors, pools, and wallets. Resource Types: @@ -86,14 +87,12 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d # ============================================ if request.resource_type == "chains": if request.action != "list": - raise ToolError(f"Only 'list' action is supported for chains, got: {request.action}") + raise ToolError( + f"Only 'list' action is supported for chains, got: {request.action}" + ) result = await client.gateway.list_chains() - return { - "resource_type": "chains", - "action": "list", - "result": result - } + return {"resource_type": "chains", "action": "list", "result": result} # ============================================ # NETWORKS @@ -101,11 +100,7 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d elif request.resource_type == "networks": if request.action == "list": result = await client.gateway.list_networks() - return { - "resource_type": "networks", - "action": "list", - "result": result - } + return {"resource_type": "networks", "action": "list", "result": result} elif request.action == "get": if not request.network_id: @@ -116,24 +111,25 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "resource_type": "networks", "action": "get", "network_id": request.network_id, - "result": result + "result": result, } elif request.action == "update": if not request.network_id: raise ToolError("network_id is required for 'update' network action") if not request.config_updates: - raise ToolError("config_updates is required for 'update' network action") + raise ToolError( + "config_updates is required for 'update' network action" + ) result = await client.gateway.update_network_config( - request.network_id, - request.config_updates + request.network_id, request.config_updates ) return { "resource_type": "networks", "action": "update", "network_id": request.network_id, - "result": result + "result": result, } else: @@ -151,15 +147,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d raise ToolError("network_id is required for 'list' tokens action") result = await client.gateway.get_network_tokens( - request.network_id, - search=request.search + request.network_id, search=request.search ) return { "resource_type": "tokens", "action": "list", "network_id": request.network_id, "search": request.search, - "result": result + "result": result, } elif request.action == "add": @@ -177,7 +172,7 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d address=request.token_address, symbol=request.token_symbol, decimals=request.token_decimals, - name=request.token_name + name=request.token_name, ) return { "resource_type": "tokens", @@ -187,9 +182,9 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "address": request.token_address, "symbol": request.token_symbol, "decimals": request.token_decimals, - "name": request.token_name + "name": request.token_name, }, - "result": result + "result": result, } elif request.action == "delete": @@ -199,15 +194,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d raise ToolError("token_address is required for 'delete' token action") result = await client.gateway.delete_token( - network_id=request.network_id, - token_address=request.token_address + network_id=request.network_id, token_address=request.token_address ) return { "resource_type": "tokens", "action": "delete", "network_id": request.network_id, "token_address": request.token_address, - "result": result + "result": result, } elif request.action == "save": @@ -221,15 +215,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d raise ToolError("token_address is required for 'save' token action") result = await client.gateway.save_network_token( - network_id=request.network_id, - token_address=request.token_address + network_id=request.network_id, token_address=request.token_address ) return { "resource_type": "tokens", "action": "save", "network_id": request.network_id, "token_address": request.token_address, - "result": result + "result": result, } else: @@ -244,11 +237,7 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d elif request.resource_type == "connectors": if request.action == "list": result = await client.gateway.list_connectors() - return { - "resource_type": "connectors", - "action": "list", - "result": result - } + return {"resource_type": "connectors", "action": "list", "result": result} elif request.action == "get": if not request.connector_name: @@ -259,24 +248,27 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "resource_type": "connectors", "action": "get", "connector_name": request.connector_name, - "result": result + "result": result, } elif request.action == "update": if not request.connector_name: - raise ToolError("connector_name is required for 'update' connector action") + raise ToolError( + "connector_name is required for 'update' connector action" + ) if not request.config_updates: - raise ToolError("config_updates is required for 'update' connector action") + raise ToolError( + "config_updates is required for 'update' connector action" + ) result = await client.gateway.update_connector_config( - request.connector_name, - request.config_updates + request.connector_name, request.config_updates ) return { "resource_type": "connectors", "action": "update", "connector_name": request.connector_name, - "result": result + "result": result, } else: @@ -300,14 +292,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d network_id=request.network_id, connector=request.connector_name, # Optional filter pool_type=request.pool_type, # Optional filter - search=request.search # Optional search + search=request.search, # Optional search ) return { "resource_type": "pools", "action": "list", "network_id": request.network_id, "connector": request.connector_name, - "result": result + "result": result, } elif request.action == "add": @@ -329,7 +321,7 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d pool_type=request.pool_type, address=request.pool_address, base=request.pool_base, - quote=request.pool_quote + quote=request.pool_quote, ) return { "resource_type": "pools", @@ -340,9 +332,9 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "type": request.pool_type, "base": request.pool_base, "quote": request.pool_quote, - "address": request.pool_address + "address": request.pool_address, }, - "result": result + "result": result, } elif request.action == "delete": @@ -357,14 +349,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d result = await client.gateway.delete_network_pool( network_id=request.network_id, address=request.pool_address, - pool_type=request.pool_type + pool_type=request.pool_type, ) return { "resource_type": "pools", "action": "delete", "network_id": request.network_id, "pool_address": request.pool_address, - "result": result + "result": result, } elif request.action == "save": @@ -378,15 +370,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d raise ToolError("pool_address is required for 'save' pool action") result = await client.gateway.save_network_pool( - network_id=request.network_id, - pool_address=request.pool_address + network_id=request.network_id, pool_address=request.pool_address ) return { "resource_type": "pools", "action": "save", "network_id": request.network_id, "pool_address": request.pool_address, - "result": result + "result": result, } else: @@ -406,14 +397,13 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d raise ToolError("private_key is required for 'add' wallet action") result = await client.accounts.add_gateway_wallet( - chain=request.chain, - private_key=request.private_key + chain=request.chain, private_key=request.private_key ) return { "resource_type": "wallets", "action": "add", "chain": request.chain, - "result": result + "result": result, } elif request.action == "delete": @@ -423,15 +413,14 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d raise ToolError("wallet_address is required for 'delete' wallet action") result = await client.accounts.remove_gateway_wallet( - chain=request.chain, - address=request.wallet_address + chain=request.chain, address=request.wallet_address ) return { "resource_type": "wallets", "action": "delete", "chain": request.chain, "wallet_address": request.wallet_address, - "result": result + "result": result, } else: diff --git a/mcp_servers/hummingbot_api/tools/gateway_amm.py b/mcp_servers/hummingbot_api/tools/gateway_amm.py index c37ac464..6a9dc7a9 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_amm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_amm.py @@ -10,6 +10,7 @@ positions[] breakdown, and positions_owned lists all of a wallet's positions. Fungible-LP AMMs (raydium, uniswap) ignore position_address and have no enumerable positions. """ + from decimal import Decimal from pathlib import Path from typing import Any @@ -66,7 +67,9 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: f"Unsupported AMM connector '{request.connector}'. Supported: {', '.join(sorted(SUPPORTED_CONNECTORS))}" ) if not request.network: - raise ToolError("network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')") + raise ToolError( + "network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')" + ) net = request.network action = request.action @@ -75,12 +78,16 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: if action == "pool_info": _require(request, "pool_address") - result = await ga.get_pool_info(connector=connector, network=net, pool_address=request.pool_address) + result = await ga.get_pool_info( + connector=connector, network=net, pool_address=request.pool_address + ) elif action == "position_info": _require(request, "pool_address") result = await ga.get_position_info( - connector=connector, network=net, pool_address=request.pool_address, + connector=connector, + network=net, + pool_address=request.pool_address, wallet_address=request.wallet_address, ) @@ -90,28 +97,41 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: f"positions_owned is not supported for '{connector}': fungible-LP AMMs have no enumerable " "positions. Use position_info with a specific pool_address instead." ) - result = await ga.get_positions_owned(connector=connector, network=net, wallet_address=request.wallet_address) + result = await ga.get_positions_owned( + connector=connector, network=net, wallet_address=request.wallet_address + ) elif action == "quote_swap": _require(request, "pool_address", "base_token", "side", "amount") result = await ga.get_swap_quote( - connector=connector, network=net, pool_address=request.pool_address, - base_token=request.base_token, side=request.side, amount=_dec(request.amount, "amount"), + connector=connector, + network=net, + pool_address=request.pool_address, + base_token=request.base_token, + side=request.side, + amount=_dec(request.amount, "amount"), slippage_pct=_opt_dec(request.slippage_pct), ) elif action == "execute_swap": _require(request, "pool_address", "base_token", "side", "amount") result = await ga.execute_swap( - connector=connector, network=net, pool_address=request.pool_address, - base_token=request.base_token, side=request.side, amount=_dec(request.amount, "amount"), - slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + connector=connector, + network=net, + pool_address=request.pool_address, + base_token=request.base_token, + side=request.side, + amount=_dec(request.amount, "amount"), + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, ) elif action == "quote_liquidity": _require(request, "pool_address", "base_token_amount", "quote_token_amount") result = await ga.get_liquidity_quote( - connector=connector, network=net, pool_address=request.pool_address, + connector=connector, + network=net, + pool_address=request.pool_address, base_token_amount=_dec(request.base_token_amount, "base_token_amount"), quote_token_amount=_dec(request.quote_token_amount, "quote_token_amount"), slippage_pct=_opt_dec(request.slippage_pct), @@ -121,10 +141,13 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: _require(request, "pool_address", "base_token_amount", "quote_token_amount") # Meteora: position_address optional (omit = new position). Fungible-LP: ignored. result = await ga.add_liquidity( - connector=connector, network=net, pool_address=request.pool_address, + connector=connector, + network=net, + pool_address=request.pool_address, base_token_amount=_dec(request.base_token_amount, "base_token_amount"), quote_token_amount=_dec(request.quote_token_amount, "quote_token_amount"), - slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, position_address=request.position_address, ) @@ -137,9 +160,14 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: "and a wallet may hold several per pool. List them with position_info or positions_owned." ) result = await ga.remove_liquidity( - connector=connector, network=net, pool_address=request.pool_address, - percentage_to_remove=_dec(request.percentage_to_remove, "percentage_to_remove"), - position_address=request.position_address, slippage_pct=_opt_dec(request.slippage_pct), + connector=connector, + network=net, + pool_address=request.pool_address, + percentage_to_remove=_dec( + request.percentage_to_remove, "percentage_to_remove" + ), + position_address=request.position_address, + slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, ) @@ -152,12 +180,17 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: "avoid token-launch configs whose base fee starts near 99%." ) result = await ga.create_pool( - connector=connector, network=net, base_token=request.base_token, quote_token=request.quote_token, + connector=connector, + network=net, + base_token=request.base_token, + quote_token=request.quote_token, base_token_amount=_dec(request.base_token_amount, "base_token_amount"), quote_token_amount=_opt_dec(request.quote_token_amount), initial_price=_opt_dec(request.initial_price), - config_address=request.config_address, fee_config_index=request.fee_config_index, - gas_price=_opt_dec(request.gas_price), max_gas=request.max_gas, + config_address=request.config_address, + fee_config_index=request.fee_config_index, + gas_price=_opt_dec(request.gas_price), + max_gas=request.max_gas, wallet_address=request.wallet_address, ) diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index ed332927..189f4fce 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -7,6 +7,7 @@ For opening/closing LP positions, use `manage_executors` with `lp_executor` type. """ + import logging from typing import Any @@ -74,16 +75,16 @@ def format_pools_as_detailed_table(pools: list[dict[str, Any]]) -> str: rows = [] for pool in pools: # Extract nested volume fields - volume = pool.get('volume', {}) - volume_hour_1 = volume.get('hour_1', 'N/A') - volume_hour_12 = volume.get('hour_12', 'N/A') - volume_hour_24 = volume.get('hour_24', 'N/A') + volume = pool.get("volume", {}) + volume_hour_1 = volume.get("hour_1", "N/A") + volume_hour_12 = volume.get("hour_12", "N/A") + volume_hour_24 = volume.get("hour_24", "N/A") # Extract nested fee_tvl_ratio fields - fee_tvl_ratio = pool.get('fee_tvl_ratio', {}) - fee_tvl_ratio_hour_1 = fee_tvl_ratio.get('hour_1', 'N/A') - fee_tvl_ratio_hour_12 = fee_tvl_ratio.get('hour_12', 'N/A') - fee_tvl_ratio_hour_24 = fee_tvl_ratio.get('hour_24', 'N/A') + fee_tvl_ratio = pool.get("fee_tvl_ratio", {}) + fee_tvl_ratio_hour_1 = fee_tvl_ratio.get("hour_1", "N/A") + fee_tvl_ratio_hour_12 = fee_tvl_ratio.get("hour_12", "N/A") + fee_tvl_ratio_hour_24 = fee_tvl_ratio.get("hour_24", "N/A") row = ( f"{get_field(pool, 'address', default='N/A')} | " @@ -110,7 +111,9 @@ def format_pools_as_detailed_table(pools: list[dict[str, Any]]) -> str: return f"{header}\n{separator}\n" + "\n".join(rows) -async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) -> dict[str, Any]: +async def explore_gateway_clmm_pools( + client: Any, request: GatewayCLMMRequest +) -> dict[str, Any]: """ Explore Gateway CLMM pools: list pools and get pool information. @@ -136,7 +139,7 @@ async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) - search_term=request.search_term, sort_key=request.sort_key, order_by=request.order_by, - include_unknown=request.include_unknown + include_unknown=request.include_unknown, ) pools = result.get("pools", []) @@ -155,14 +158,14 @@ async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) - "search_term": request.search_term, "sort_key": request.sort_key, "order_by": request.order_by, - "include_unknown": request.include_unknown + "include_unknown": request.include_unknown, }, "pagination": { "page": request.page, "limit": request.limit, - "total": result.get("total", 0) + "total": result.get("total", 0), }, - "pools_table": formatted_table + "pools_table": formatted_table, } # ============================================ @@ -178,7 +181,7 @@ async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) - result = await client.gateway_clmm.get_pool_info( connector=request.connector, network=request.network, - pool_address=request.pool_address + pool_address=request.pool_address, ) return { @@ -186,10 +189,8 @@ async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) - "connector": request.connector, "network": request.network, "pool_address": request.pool_address, - "result": result + "result": result, } else: raise ToolError(f"Unknown action: {request.action}") - - diff --git a/mcp_servers/hummingbot_api/tools/gateway_swap.py b/mcp_servers/hummingbot_api/tools/gateway_swap.py index 309a1636..3843ef8a 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_swap.py +++ b/mcp_servers/hummingbot_api/tools/gateway_swap.py @@ -5,6 +5,7 @@ - Swap quote/execute (Router: Jupiter, 0x) - Swap search and status tracking """ + import logging from decimal import Decimal from typing import Any @@ -15,7 +16,9 @@ logger = logging.getLogger("hummingbot-mcp") -async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict[str, Any]: +async def manage_gateway_swaps( + client: Any, request: GatewaySwapRequest +) -> dict[str, Any]: """ Manage Gateway swap operations: quote, execute, search, and status tracking. @@ -47,7 +50,9 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict # Parse trading pair if "-" not in request.trading_pair: - raise ToolError(f"Invalid trading_pair format. Expected 'BASE-QUOTE', got '{request.trading_pair}'") + raise ToolError( + f"Invalid trading_pair format. Expected 'BASE-QUOTE', got '{request.trading_pair}'" + ) result = await client.gateway_swap.get_swap_quote( connector=request.connector, @@ -55,7 +60,7 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict trading_pair=request.trading_pair, side=request.side, amount=Decimal(request.amount), - slippage_pct=Decimal(request.slippage_pct or "1.0") + slippage_pct=Decimal(request.slippage_pct or "1.0"), ) return { @@ -63,7 +68,7 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict "trading_pair": request.trading_pair, "side": request.side, "amount": request.amount, - "result": result + "result": result, } # ============================================ @@ -84,7 +89,9 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict # Parse trading pair if "-" not in request.trading_pair: - raise ToolError(f"Invalid trading_pair format. Expected 'BASE-QUOTE', got '{request.trading_pair}'") + raise ToolError( + f"Invalid trading_pair format. Expected 'BASE-QUOTE', got '{request.trading_pair}'" + ) result = await client.gateway_swap.execute_swap( connector=request.connector, @@ -93,7 +100,7 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict side=request.side, amount=Decimal(request.amount), slippage_pct=Decimal(request.slippage_pct or "1.0"), - wallet_address=request.wallet_address + wallet_address=request.wallet_address, ) return { @@ -102,7 +109,7 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict "side": request.side, "amount": request.amount, "wallet_address": request.wallet_address or "(default)", - "result": result + "result": result, } # ============================================ @@ -117,7 +124,7 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict return { "action": "get_status", "transaction_hash": request.transaction_hash, - "result": result + "result": result, } # ============================================ @@ -125,10 +132,7 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict # ============================================ elif request.action == "search": # Build search filters - search_params = { - "limit": request.limit or 50, - "offset": request.offset or 0 - } + search_params = {"limit": request.limit or 50, "offset": request.offset or 0} # Add optional filters if request.search_network: @@ -150,12 +154,14 @@ async def manage_gateway_swaps(client: Any, request: GatewaySwapRequest) -> dict return { "action": "search", - "filters": {k: v for k, v in search_params.items() if k not in ["limit", "offset"]}, + "filters": { + k: v for k, v in search_params.items() if k not in ["limit", "offset"] + }, "pagination": { "limit": search_params["limit"], - "offset": search_params["offset"] + "offset": search_params["offset"], }, - "result": result + "result": result, } else: diff --git a/mcp_servers/hummingbot_api/tools/geckoterminal.py b/mcp_servers/hummingbot_api/tools/geckoterminal.py index 3b9db361..05bb675c 100644 --- a/mcp_servers/hummingbot_api/tools/geckoterminal.py +++ b/mcp_servers/hummingbot_api/tools/geckoterminal.py @@ -8,11 +8,17 @@ - OHLCV candle data - Recent trades """ + import logging from typing import Any from mcp_servers.hummingbot_api.exceptions import ToolError -from mcp_servers.hummingbot_api.formatters.base import format_currency, format_number, format_timestamp, truncate_address +from mcp_servers.hummingbot_api.formatters.base import ( + format_currency, + format_number, + format_timestamp, + truncate_address, +) logger = logging.getLogger("hummingbot-mcp") @@ -26,15 +32,23 @@ def _parse_timeframe(timeframe: str) -> tuple[str, str]: """Parse '1h' into ('hour', '1').""" if timeframe not in OHLCV_TIMEFRAMES: - raise ToolError(f"Unsupported timeframe '{timeframe}'. Use one of: {OHLCV_TIMEFRAMES}") + raise ToolError( + f"Unsupported timeframe '{timeframe}'. Use one of: {OHLCV_TIMEFRAMES}" + ) period, unit = timeframe[:-1], timeframe[-1] return TIMEFRAME_UNIT_MAP[unit], period def _extract_networks(response: dict) -> list[dict[str, Any]]: return [ - {"id": item["id"], "type": item["type"], "name": item["attributes"]["name"], - "coingecko_asset_platform_id": item["attributes"].get("coingecko_asset_platform_id")} + { + "id": item["id"], + "type": item["type"], + "name": item["attributes"]["name"], + "coingecko_asset_platform_id": item["attributes"].get( + "coingecko_asset_platform_id" + ), + } for item in response.get("data", []) ] @@ -108,7 +122,14 @@ def _extract_trades(response: dict) -> list[dict[str, Any]]: def _extract_ohlcv(response: dict) -> list[dict[str, Any]]: ohlcv_list = response.get("data", {}).get("attributes", {}).get("ohlcv_list", []) return [ - {"timestamp": row[0], "open": row[1], "high": row[2], "low": row[3], "close": row[4], "volume_usd": row[5]} + { + "timestamp": row[0], + "open": row[1], + "high": row[2], + "low": row[3], + "close": row[4], + "volume_usd": row[5], + } for row in ohlcv_list ] @@ -127,7 +148,11 @@ def _extract_token_info(response: dict) -> dict[str, Any]: "fdv_usd": attrs.get("fdv_usd"), "market_cap_usd": attrs.get("market_cap_usd"), "total_reserve_in_usd": attrs.get("total_reserve_in_usd"), - "volume_usd_h24": attrs.get("volume_usd", {}).get("h24") if isinstance(attrs.get("volume_usd"), dict) else None, + "volume_usd_h24": ( + attrs.get("volume_usd", {}).get("h24") + if isinstance(attrs.get("volume_usd"), dict) + else None + ), } @@ -164,8 +189,14 @@ def format_pools_table(pools: list[dict]) -> str: price = format_currency(p.get("base_token_price_usd"), decimals=4) reserve = format_number(p.get("reserve_in_usd")) vol = format_number(p.get("volume_h24")) - chg = f"{float(p['price_change_h24']):.2f}%" if p.get("price_change_h24") is not None else "N/A" - rows.append(f"{name:31} | {addr:44} | {price:16} | {reserve:16} | {vol:16} | {chg}") + chg = ( + f"{float(p['price_change_h24']):.2f}%" + if p.get("price_change_h24") is not None + else "N/A" + ) + rows.append( + f"{name:31} | {addr:44} | {price:16} | {reserve:16} | {vol:16} | {chg}" + ) return f"{header}\n{sep}\n" + "\n".join(rows) @@ -197,7 +228,9 @@ def format_trades_table(trades: list[dict]) -> str: from_amt = format_number(t.get("from_token_amount"), 4, False) to_amt = format_number(t.get("to_token_amount"), 4, False) tx = truncate_address(t.get("tx_hash") or "N/A", 8, 6) - rows.append(f"{dt:19} | {side:4} | {vol:16} | {from_amt:16} | {to_amt:16} | {tx}") + rows.append( + f"{dt:19} | {side:4} | {vol:16} | {from_amt:16} | {to_amt:16} | {tx}" + ) return f"{header}\n{sep}\n" + "\n".join(rows) @@ -247,7 +280,9 @@ async def _get(path: str, params: dict | None = None) -> dict: if action == "networks": data = await _get("networks") networks = _extract_networks(data) - return {"formatted_output": f"Available Networks ({len(networks)}):\n\n{format_networks_table(networks)}"} + return { + "formatted_output": f"Available Networks ({len(networks)}):\n\n{format_networks_table(networks)}" + } # ── DEXes by network ───────────────────────────────────────── elif action == "dexes": @@ -255,7 +290,9 @@ async def _get(path: str, params: dict | None = None) -> dict: raise ToolError("'network' is required for action='dexes'") data = await _get(f"networks/{network}/dexes") dexes = _extract_dexes(data) - return {"formatted_output": f"DEXes on {network} ({len(dexes)}):\n\n{format_dexes_table(dexes)}"} + return { + "formatted_output": f"DEXes on {network} ({len(dexes)}):\n\n{format_dexes_table(dexes)}" + } # ── Trending pools ─────────────────────────────────────────── elif action == "trending_pools": @@ -266,7 +303,9 @@ async def _get(path: str, params: dict | None = None) -> dict: data = await _get("networks/trending_pools") title = "Trending Pools (All Networks)" pools = _extract_pools(data) - return {"formatted_output": f"{title} ({len(pools)}):\n\n{format_pools_table(pools)}"} + return { + "formatted_output": f"{title} ({len(pools)}):\n\n{format_pools_table(pools)}" + } # ── Top pools ──────────────────────────────────────────────── elif action == "top_pools": @@ -279,7 +318,9 @@ async def _get(path: str, params: dict | None = None) -> dict: data = await _get(f"networks/{network}/pools") title = f"Top Pools on {network}" pools = _extract_pools(data) - return {"formatted_output": f"{title} ({len(pools)}):\n\n{format_pools_table(pools)}"} + return { + "formatted_output": f"{title} ({len(pools)}):\n\n{format_pools_table(pools)}" + } # ── New pools ──────────────────────────────────────────────── elif action == "new_pools": @@ -290,12 +331,16 @@ async def _get(path: str, params: dict | None = None) -> dict: data = await _get("networks/new_pools") title = "New Pools (All Networks)" pools = _extract_pools(data) - return {"formatted_output": f"{title} ({len(pools)}):\n\n{format_pools_table(pools)}"} + return { + "formatted_output": f"{title} ({len(pools)}):\n\n{format_pools_table(pools)}" + } # ── Pool detail ────────────────────────────────────────────── elif action == "pool_detail": if not network or not pool_address: - raise ToolError("'network' and 'pool_address' are required for action='pool_detail'") + raise ToolError( + "'network' and 'pool_address' are required for action='pool_detail'" + ) data = await _get(f"networks/{network}/pools/{pool_address}") pools = _extract_pools(data) if pools: @@ -321,24 +366,34 @@ async def _get(path: str, params: dict | None = None) -> dict: # ── Multiple pools ─────────────────────────────────────────── elif action == "multi_pools": if not network or not pool_addresses: - raise ToolError("'network' and 'pool_addresses' are required for action='multi_pools'") + raise ToolError( + "'network' and 'pool_addresses' are required for action='multi_pools'" + ) addresses_str = ",".join(pool_addresses) data = await _get(f"networks/{network}/pools/multi/{addresses_str}") pools = _extract_pools(data) - return {"formatted_output": f"Pools on {network} ({len(pools)}):\n\n{format_pools_table(pools)}"} + return { + "formatted_output": f"Pools on {network} ({len(pools)}):\n\n{format_pools_table(pools)}" + } # ── Pools by token ─────────────────────────────────────────── elif action == "token_pools": if not network or not token_address: - raise ToolError("'network' and 'token_address' are required for action='token_pools'") + raise ToolError( + "'network' and 'token_address' are required for action='token_pools'" + ) data = await _get(f"networks/{network}/tokens/{token_address}/pools") pools = _extract_pools(data) - return {"formatted_output": f"Top Pools for token on {network} ({len(pools)}):\n\n{format_pools_table(pools)}"} + return { + "formatted_output": f"Top Pools for token on {network} ({len(pools)}):\n\n{format_pools_table(pools)}" + } # ── Token info ─────────────────────────────────────────────── elif action == "token_info": if not network or not token_address: - raise ToolError("'network' and 'token_address' are required for action='token_info'") + raise ToolError( + "'network' and 'token_address' are required for action='token_info'" + ) data = await _get(f"networks/{network}/tokens/{token_address}") token_data = _extract_token_info(data) return {"formatted_output": format_token_info(token_data)} @@ -346,12 +401,22 @@ async def _get(path: str, params: dict | None = None) -> dict: # ── OHLCV candles ──────────────────────────────────────────── elif action == "ohlcv": if not network or not pool_address: - raise ToolError("'network' and 'pool_address' are required for action='ohlcv'") + raise ToolError( + "'network' and 'pool_address' are required for action='ohlcv'" + ) tf_unit, tf_period = _parse_timeframe(timeframe) - params: dict[str, Any] = {"aggregate": tf_period, "limit": limit, "currency": currency, "token": token} + params: dict[str, Any] = { + "aggregate": tf_period, + "limit": limit, + "currency": currency, + "token": token, + } if before_timestamp: params["before_timestamp"] = before_timestamp - data = await _get(f"networks/{network}/pools/{pool_address}/ohlcv/{tf_unit}", params=params) + data = await _get( + f"networks/{network}/pools/{pool_address}/ohlcv/{tf_unit}", + params=params, + ) candles = _extract_ohlcv(data) # Sort by timestamp ascending and deduplicate seen: set[int] = set() @@ -361,24 +426,32 @@ async def _get(path: str, params: dict | None = None) -> dict: seen.add(c["timestamp"]) unique.append(c) unique.sort(key=lambda x: x["timestamp"]) - return {"formatted_output": ( - f"OHLCV for pool {truncate_address(pool_address)} on {network} ({timeframe}, {len(unique)} candles):\n\n" - f"{format_ohlcv_table(unique)}" - )} + return { + "formatted_output": ( + f"OHLCV for pool {truncate_address(pool_address)} on {network} ({timeframe}, {len(unique)} candles):\n\n" + f"{format_ohlcv_table(unique)}" + ) + } # ── Trades ─────────────────────────────────────────────────── elif action == "trades": if not network or not pool_address: - raise ToolError("'network' and 'pool_address' are required for action='trades'") + raise ToolError( + "'network' and 'pool_address' are required for action='trades'" + ) params = {} if trade_volume_filter is not None: params["trade_volume_in_usd_greater_than"] = trade_volume_filter - data = await _get(f"networks/{network}/pools/{pool_address}/trades", params=params or None) + data = await _get( + f"networks/{network}/pools/{pool_address}/trades", params=params or None + ) trades = _extract_trades(data) - return {"formatted_output": ( - f"Recent Trades for pool {truncate_address(pool_address)} on {network} ({len(trades)}):\n\n" - f"{format_trades_table(trades)}" - )} + return { + "formatted_output": ( + f"Recent Trades for pool {truncate_address(pool_address)} on {network} ({len(trades)}):\n\n" + f"{format_trades_table(trades)}" + ) + } else: raise ToolError( diff --git a/mcp_servers/hummingbot_api/tools/history.py b/mcp_servers/hummingbot_api/tools/history.py index 9d1745e3..6fde5af5 100644 --- a/mcp_servers/hummingbot_api/tools/history.py +++ b/mcp_servers/hummingbot_api/tools/history.py @@ -6,12 +6,14 @@ - Perpetual positions (open and closed) - CLMM positions (open and closed) """ + import logging from typing import Any, Literal from mcp_servers.hummingbot_api.hummingbot_client import HummingbotClient -from . import trading as trading_tools + from . import gateway_clmm as gateway_clmm_tools +from . import trading as trading_tools logger = logging.getLogger("hummingbot-mcp") @@ -83,14 +85,16 @@ async def search_history( formatted_output = f"Order History\n{'=' * 100}\n\n{result['orders_table']}" - if result['pagination'].get('has_more'): - formatted_output += f"\n\n... and more (use offset={offset + limit} to see more)" + if result["pagination"].get("has_more"): + formatted_output += ( + f"\n\n... and more (use offset={offset + limit} to see more)" + ) return { "data_type": "orders", - "total_count": result['total_returned'], - "results": result['orders'], - "formatted_output": formatted_output + "total_count": result["total_returned"], + "results": result["orders"], + "formatted_output": formatted_output, } # ============================================ @@ -109,9 +113,9 @@ async def search_history( return { "data_type": "perp_positions", - "total_count": result['total_positions'], - "results": result['positions'], - "formatted_output": formatted_output + "total_count": result["total_positions"], + "results": result["positions"], + "formatted_output": formatted_output, } # ============================================ @@ -131,9 +135,13 @@ async def search_history( if wallet_address: search_params["wallet_address"] = wallet_address if connector_names: - search_params["connector"] = connector_names[0] if len(connector_names) == 1 else None + search_params["connector"] = ( + connector_names[0] if len(connector_names) == 1 else None + ) if trading_pairs: - search_params["trading_pair"] = trading_pairs[0] if len(trading_pairs) == 1 else None + search_params["trading_pair"] = ( + trading_pairs[0] if len(trading_pairs) == 1 else None + ) if status: search_params["status"] = status if position_addresses: @@ -147,7 +155,7 @@ async def search_history( "data_type": "clmm_positions", "total_count": 0, "results": [], - "formatted_output": "No CLMM positions found" + "formatted_output": "No CLMM positions found", } positions = result.get("data", []) @@ -170,7 +178,11 @@ async def search_history( upper = f"{float(pos.get('upper_price', 0)):.4f}"[:10] status_val = pos.get("status", "N/A")[:8] created = pos.get("created_at", "N/A")[:20] - closed = pos.get("closed_at", "N/A")[:20] if pos.get("closed_at") else "-" + closed = ( + pos.get("closed_at", "N/A")[:20] + if pos.get("closed_at") + else "-" + ) table_lines.append( f"{connector:<10} | {network:<20} | {pair:<15} | {lower:<10} | {upper:<10} | " @@ -178,7 +190,9 @@ async def search_history( ) if total_count > limit: - table_lines.append(f"\n... and {total_count - limit} more positions (use offset={offset + limit} to see more)") + table_lines.append( + f"\n... and {total_count - limit} more positions (use offset={offset + limit} to see more)" + ) formatted_output = "\n".join(table_lines) else: @@ -188,7 +202,7 @@ async def search_history( "data_type": "clmm_positions", "total_count": total_count, "results": positions, - "formatted_output": formatted_output + "formatted_output": formatted_output, } else: @@ -196,7 +210,7 @@ async def search_history( "data_type": data_type, "total_count": 0, "results": [], - "formatted_output": f"Unknown data type: {data_type}" + "formatted_output": f"Unknown data type: {data_type}", } except Exception as e: diff --git a/mcp_servers/hummingbot_api/tools/market_data.py b/mcp_servers/hummingbot_api/tools/market_data.py index f2f807cb..992c2085 100644 --- a/mcp_servers/hummingbot_api/tools/market_data.py +++ b/mcp_servers/hummingbot_api/tools/market_data.py @@ -4,6 +4,7 @@ This module provides the core business logic for market data operations including prices, candles, funding rates, and order books. """ + from datetime import datetime from typing import Any, Literal @@ -230,7 +231,9 @@ async def get_order_book( else: # Handle query-based requests if query_value is None: - raise ValueError(f"query_value must be provided for query_type '{query_type}'") + raise ValueError( + f"query_value must be provided for query_type '{query_type}'" + ) # Execute appropriate query if query_type == "volume_for_price": diff --git a/mcp_servers/hummingbot_api/tools/portfolio.py b/mcp_servers/hummingbot_api/tools/portfolio.py index d0224648..88ff5778 100644 --- a/mcp_servers/hummingbot_api/tools/portfolio.py +++ b/mcp_servers/hummingbot_api/tools/portfolio.py @@ -6,13 +6,14 @@ - Perpetual positions from CEX - LP positions (CLMM) from blockchain DEXs """ + import asyncio import logging from typing import Any, Literal from mcp_servers.hummingbot_api.exceptions import ToolError -from mcp_servers.hummingbot_api.hummingbot_client import HummingbotClient from mcp_servers.hummingbot_api.formatters import format_portfolio_as_table +from mcp_servers.hummingbot_api.hummingbot_client import HummingbotClient from mcp_servers.hummingbot_api.tools import trading as trading_tools logger = logging.getLogger("hummingbot-mcp") @@ -69,6 +70,7 @@ async def get_portfolio_overview( # Task 1: Get token balances if include_balances: + async def get_balances(): try: return await client.portfolio.get_state( @@ -85,6 +87,7 @@ async def get_balances(): # Task 2: Get perpetual positions if include_perp_positions: + async def get_perp_positions(): try: return await trading_tools.get_positions( @@ -102,6 +105,7 @@ async def get_perp_positions(): # Task 3: Get LP positions (CLMM) - Real-time from blockchain if include_lp_positions: + async def get_lp_positions(): try: # Step 1: Get all unique pools from database (to know which pools to query) @@ -145,10 +149,12 @@ async def fetch_pool(connector, network, pool_address): connector=connector, network=network, pool_address=pool_address, - wallet_address=None # Uses default wallet + wallet_address=None, # Uses default wallet ) except Exception as e: - logger.warning(f"Failed to get positions for pool {pool_address}: {str(e)}") + logger.warning( + f"Failed to get positions for pool {pool_address}: {str(e)}" + ) return None pool_results = await asyncio.gather( @@ -179,6 +185,7 @@ async def fetch_pool(connector, network, pool_address): # Task 4: Get active orders if include_active_orders: + async def get_active_orders(): try: return await trading_tools.search_orders( @@ -230,21 +237,29 @@ async def get_active_orders(): total_value += balance_value # Format balances as table - balances_table = format_portfolio_as_table(balances_data) if balances_data else "No balances found" - - sections.append({ - "title": "Token Balances", - "content": balances_table, - "total_value": balance_value, - "emoji": "💰" - }) + balances_table = ( + format_portfolio_as_table(balances_data) + if balances_data + else "No balances found" + ) + + sections.append( + { + "title": "Token Balances", + "content": balances_table, + "total_value": balance_value, + "emoji": "💰", + } + ) elif include_balances and not data.get("balances"): - sections.append({ - "title": "Token Balances", - "content": "Failed to fetch balances", - "total_value": 0.0, - "emoji": "⚠️" - }) + sections.append( + { + "title": "Token Balances", + "content": "Failed to fetch balances", + "total_value": 0.0, + "emoji": "⚠️", + } + ) # ============================================ # SECTION 2: Perpetual Positions @@ -260,26 +275,32 @@ async def get_active_orders(): # Note: You'll need to parse the table or enhance trading_tools.get_positions # to return structured data with PnL values - sections.append({ - "title": "Perpetual Positions", - "content": perp_table, - "total_positions": total_positions, - "emoji": "📊" - }) + sections.append( + { + "title": "Perpetual Positions", + "content": perp_table, + "total_positions": total_positions, + "emoji": "📊", + } + ) else: - sections.append({ + sections.append( + { + "title": "Perpetual Positions", + "content": "No perpetual positions found", + "total_positions": 0, + "emoji": "📊", + } + ) + elif include_perp_positions and not data.get("perp_positions"): + sections.append( + { "title": "Perpetual Positions", - "content": "No perpetual positions found", + "content": "Failed to fetch perpetual positions", "total_positions": 0, - "emoji": "📊" - }) - elif include_perp_positions and not data.get("perp_positions"): - sections.append({ - "title": "Perpetual Positions", - "content": "Failed to fetch perpetual positions", - "total_positions": 0, - "emoji": "⚠️" - }) + "emoji": "⚠️", + } + ) # ============================================ # SECTION 3: LP Positions (CLMM) - Real-time data @@ -297,7 +318,9 @@ async def get_active_orders(): # Format LP positions - show all open positions with real-time data if open_positions: lp_table_lines = ["Status: OPEN positions", ""] - lp_table_lines.append("connector | trading_pair | lower_price | upper_price | position_address") + lp_table_lines.append( + "connector | trading_pair | lower_price | upper_price | position_address" + ) lp_table_lines.append("-" * 100) for pos in open_positions[:10]: # Show up to 10 open positions @@ -308,13 +331,17 @@ async def get_active_orders(): position_address = pos.get("position_address", "N/A") # Format prices - if lower_price != "N/A" and isinstance(lower_price, (int, float, str)): + if lower_price != "N/A" and isinstance( + lower_price, (int, float, str) + ): try: lower_price = f"{float(lower_price):.4f}" except: pass - if upper_price != "N/A" and isinstance(upper_price, (int, float, str)): + if upper_price != "N/A" and isinstance( + upper_price, (int, float, str) + ): try: upper_price = f"{float(upper_price):.4f}" except: @@ -322,40 +349,50 @@ async def get_active_orders(): # Truncate position address if position_address != "N/A" and len(position_address) > 20: - position_address = f"{position_address[:8]}...{position_address[-6:]}" + position_address = ( + f"{position_address[:8]}...{position_address[-6:]}" + ) lp_table_lines.append( f"{connector[:10]:10} | {trading_pair[:15]:15} | {str(lower_price)[:11]:11} | {str(upper_price)[:11]:11} | {position_address}" ) if len(open_positions) > 10: - lp_table_lines.append(f"... and {len(open_positions) - 10} more open positions") + lp_table_lines.append( + f"... and {len(open_positions) - 10} more open positions" + ) lp_table = "\n".join(lp_table_lines) else: lp_table = "No active LP positions found" - sections.append({ - "title": "LP Positions (CLMM)", - "content": lp_table, - "total_positions": total_lp_positions, - "open_positions": len(open_positions), - "emoji": "🏊" - }) + sections.append( + { + "title": "LP Positions (CLMM)", + "content": lp_table, + "total_positions": total_lp_positions, + "open_positions": len(open_positions), + "emoji": "🏊", + } + ) else: - sections.append({ + sections.append( + { + "title": "LP Positions (CLMM)", + "content": "No LP positions found", + "total_positions": 0, + "emoji": "🏊", + } + ) + elif include_lp_positions and not data.get("lp_positions"): + sections.append( + { "title": "LP Positions (CLMM)", - "content": "No LP positions found", + "content": "Failed to fetch LP positions", "total_positions": 0, - "emoji": "🏊" - }) - elif include_lp_positions and not data.get("lp_positions"): - sections.append({ - "title": "LP Positions (CLMM)", - "content": "Failed to fetch LP positions", - "total_positions": 0, - "emoji": "⚠️" - }) + "emoji": "⚠️", + } + ) # ============================================ # SECTION 4: Active Orders @@ -367,26 +404,32 @@ async def get_active_orders(): orders_table = orders_data.get("orders_table", "No active orders found") total_orders = orders_data.get("total_returned", 0) - sections.append({ - "title": "Active Orders", - "content": orders_table, - "total_orders": total_orders, - "emoji": "📋" - }) + sections.append( + { + "title": "Active Orders", + "content": orders_table, + "total_orders": total_orders, + "emoji": "📋", + } + ) else: - sections.append({ + sections.append( + { + "title": "Active Orders", + "content": "No active orders found", + "total_orders": 0, + "emoji": "📋", + } + ) + elif include_active_orders and not data.get("active_orders"): + sections.append( + { "title": "Active Orders", - "content": "No active orders found", + "content": "Failed to fetch active orders", "total_orders": 0, - "emoji": "📋" - }) - elif include_active_orders and not data.get("active_orders"): - sections.append({ - "title": "Active Orders", - "content": "Failed to fetch active orders", - "total_orders": 0, - "emoji": "⚠️" - }) + "emoji": "⚠️", + } + ) # ============================================ # Build final formatted output @@ -404,23 +447,35 @@ async def get_active_orders(): output_lines.append("-" * 80) if include_balances: - balance_section = next((s for s in sections if s["title"] == "Token Balances"), None) + balance_section = next( + (s for s in sections if s["title"] == "Token Balances"), None + ) if balance_section and "total_value" in balance_section: - output_lines.append(f"Total Balance Value: ${balance_section['total_value']:.2f}") + output_lines.append( + f"Total Balance Value: ${balance_section['total_value']:.2f}" + ) if include_perp_positions: - perp_section = next((s for s in sections if s["title"] == "Perpetual Positions"), None) + perp_section = next( + (s for s in sections if s["title"] == "Perpetual Positions"), None + ) if perp_section and "total_positions" in perp_section: - output_lines.append(f"Active Perpetual Positions: {perp_section['total_positions']}") + output_lines.append( + f"Active Perpetual Positions: {perp_section['total_positions']}" + ) if include_lp_positions: - lp_section = next((s for s in sections if s["title"] == "LP Positions (CLMM)"), None) + lp_section = next( + (s for s in sections if s["title"] == "LP Positions (CLMM)"), None + ) if lp_section and "open_positions" in lp_section: open_count = lp_section.get("open_positions", 0) output_lines.append(f"Active LP Positions: {open_count}") if include_active_orders: - orders_section = next((s for s in sections if s["title"] == "Active Orders"), None) + orders_section = next( + (s for s in sections if s["title"] == "Active Orders"), None + ) if orders_section and "total_orders" in orders_section: output_lines.append(f"Active Orders: {orders_section['total_orders']}") @@ -437,7 +492,7 @@ async def get_active_orders(): "include_perp_positions": include_perp_positions, "include_lp_positions": include_lp_positions, "include_active_orders": include_active_orders, - } + }, } except Exception as e: diff --git a/mcp_servers/hummingbot_api/tools/trading.py b/mcp_servers/hummingbot_api/tools/trading.py index cf006668..11b9a279 100644 --- a/mcp_servers/hummingbot_api/tools/trading.py +++ b/mcp_servers/hummingbot_api/tools/trading.py @@ -6,9 +6,13 @@ For order placement and cancellation, use `manage_executors` with `order_executor` type. """ + from typing import Any, Literal -from mcp_servers.hummingbot_api.formatters import format_orders_as_table, format_positions_as_table +from mcp_servers.hummingbot_api.formatters import ( + format_orders_as_table, + format_positions_as_table, +) async def set_position_mode_and_leverage( @@ -48,7 +52,9 @@ async def set_position_mode_and_leverage( raise ValueError("Invalid position mode. Must be 'HEDGE' or 'ONE-WAY'") position_mode_result = await client.trading.set_position_mode( - account_name=account_name, connector_name=connector_name, position_mode=position_mode + account_name=account_name, + connector_name=connector_name, + position_mode=position_mode, ) results["position_mode"] = position_mode_result diff --git a/routines/market_scanner.py b/routines/market_scanner.py index 0454502e..286c471c 100644 --- a/routines/market_scanner.py +++ b/routines/market_scanner.py @@ -390,6 +390,7 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: text = format_results(classified, config.lookback_hours) import plotly.graph_objects as go + from condor.reports import ReportBuilder def _to_table(items): diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index b3d4c403..280f86f7 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -50,7 +50,6 @@ _custom_manage_keyboard, ) - # -- agent_key routing -- @@ -210,10 +209,16 @@ def test_active_agent_key_round_trips_through_preferences(): def test_legacy_custom_llm_is_migrated(): # The first iteration stored one endpoint in raw user_data, outside the # preference system. It should move across on first read, once. - user_data = {"custom_llm": {"base_url": "https://api.venice.ai/api/v1", "api_key": "sk-old"}} + user_data = { + "custom_llm": {"base_url": "https://api.venice.ai/api/v1", "api_key": "sk-old"} + } providers = get_custom_providers(user_data) assert providers == [ - {"name": "Venice", "base_url": "https://api.venice.ai/api/v1", "api_key": "sk-old"} + { + "name": "Venice", + "base_url": "https://api.venice.ai/api/v1", + "api_key": "sk-old", + } ] assert "custom_llm" not in user_data assert get_custom_providers(user_data) == providers @@ -228,8 +233,14 @@ def test_normalize_base_url_variants(): == "https://api.venice.ai/api/v1" ) assert normalize_base_url("api.venice.ai/api/v1/") == "https://api.venice.ai/api/v1" - assert normalize_base_url(" https://x.example/v1/chat/completions ") == "https://x.example/v1" - assert normalize_base_url("http://localhost:8000/v1/models") == "http://localhost:8000/v1" + assert ( + normalize_base_url(" https://x.example/v1/chat/completions ") + == "https://x.example/v1" + ) + assert ( + normalize_base_url("http://localhost:8000/v1/models") + == "http://localhost:8000/v1" + ) def test_normalize_base_url_rejects_garbage(): @@ -252,7 +263,9 @@ def test_normalize_base_url_blocks_metadata_endpoints(): def test_normalize_base_url_allows_local_servers_by_default(): # Local model servers are the most common use of this feature assert normalize_base_url("http://localhost:8000/v1") == "http://localhost:8000/v1" - assert normalize_base_url("http://192.168.1.5:1234/v1") == "http://192.168.1.5:1234/v1" + assert ( + normalize_base_url("http://192.168.1.5:1234/v1") == "http://192.168.1.5:1234/v1" + ) def test_normalize_base_url_strict_mode_blocks_private(monkeypatch): @@ -569,7 +582,12 @@ async def scenario(_base): def test_fetch_models_reports_when_only_non_chat_models_exist(): async def models_handler(request: web.Request) -> web.Response: return web.json_response( - {"data": [{"id": "bge-m3", "type": "embedding"}, {"id": "flux", "type": "image"}]} + { + "data": [ + {"id": "bge-m3", "type": "embedding"}, + {"id": "flux", "type": "image"}, + ] + } ) async def scenario(): diff --git a/tests/test_executor_mutation_errors.py b/tests/test_executor_mutation_errors.py index a4a894bd..2860c7cd 100644 --- a/tests/test_executor_mutation_errors.py +++ b/tests/test_executor_mutation_errors.py @@ -29,10 +29,7 @@ stop_executor, ) from condor.web.models import CreateExecutorRequest, WebUser -from condor.web.routes.executors import ( - create_executor_endpoint, - stop_executor_endpoint, -) +from condor.web.routes.executors import create_executor_endpoint, stop_executor_endpoint # The internal address a shared-server trader must never be shown. BACKEND_URL = "http://10.0.0.7:8000/executors/create" diff --git a/tests/test_executor_row_shared.py b/tests/test_executor_row_shared.py index 3126418f..32751bb9 100644 --- a/tests/test_executor_row_shared.py +++ b/tests/test_executor_row_shared.py @@ -49,7 +49,10 @@ def test_current_price_precedence_is_the_union_of_both_chains(): "close_price": 3.0, "held_position_orders": [{"price": 4.0}], } - assert build_executor_row({"current_price": 1.0, "custom_info": ci})["current_price"] == 1.0 + assert ( + build_executor_row({"current_price": 1.0, "custom_info": ci})["current_price"] + == 1.0 + ) assert build_executor_row({"custom_info": ci})["current_price"] == 2.0 assert ( build_executor_row( diff --git a/tests/test_executors_period_summary.py b/tests/test_executors_period_summary.py index 3c57abf6..1223f54b 100644 --- a/tests/test_executors_period_summary.py +++ b/tests/test_executors_period_summary.py @@ -17,10 +17,7 @@ import pytest from fastapi import HTTPException -from condor.fetchers.executors import ( - EXECUTORS_PAGE_SIZE, - summarize_executors_by_quote, -) +from condor.fetchers.executors import EXECUTORS_PAGE_SIZE, summarize_executors_by_quote from condor.web.models import WebUser from condor.web.routes.executors import _summary_cache, executors_summary @@ -119,7 +116,9 @@ def test_summary_spans_the_whole_history_not_one_page(summary_env): result = _summary("1D") - assert result.count == EXECUTORS_PAGE_SIZE + 120, "the window was truncated to a page" + assert ( + result.count == EXECUTORS_PAGE_SIZE + 120 + ), "the window was truncated to a page" assert result.pnl == pytest.approx(EXECUTORS_PAGE_SIZE + 120) assert len(client.calls) > 1, "a full-history total needs the whole walk" assert client.calls[0]["limit"] == EXECUTORS_PAGE_SIZE @@ -127,7 +126,11 @@ def test_summary_spans_the_whole_history_not_one_page(summary_env): def test_executors_outside_the_window_are_excluded(summary_env): """A 1D total holds yesterday's executors and not last week's.""" - rows = [_executor(0, age_days=0.5), _executor(1, age_days=3), _executor(2, age_days=20)] + rows = [ + _executor(0, age_days=0.5), + _executor(1, age_days=3), + _executor(2, age_days=20), + ] summary_env(rows, {"USDT-USDT": 1.0}) assert _summary("1D").count == 1 @@ -160,7 +163,9 @@ def test_an_unpriceable_quote_is_reported_not_hidden(summary_env): result = _summary("1D") - assert result.pnl == pytest.approx(4.0), "an unconvertible row is kept at face value" + assert result.pnl == pytest.approx( + 4.0 + ), "an unconvertible row is kept at face value" assert result.converted is False diff --git a/tests/test_instance_history_pagination.py b/tests/test_instance_history_pagination.py index 897d0861..fedeaeb7 100644 --- a/tests/test_instance_history_pagination.py +++ b/tests/test_instance_history_pagination.py @@ -157,7 +157,9 @@ def test_no_warning_when_the_walk_completes(caplog): assert len(hist) == 10 assert len(handler.calls) == 1 # short page ends the walk - assert [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] == [] + assert [ + r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING + ] == [] def test_stops_when_the_api_echoes_the_cursor_back(): diff --git a/tests/test_reports_attribution.py b/tests/test_reports_attribution.py index b36ba7b9..912c894c 100644 --- a/tests/test_reports_attribution.py +++ b/tests/test_reports_attribution.py @@ -188,9 +188,7 @@ async def run(config, ctx): await b.save() return "done" - return SimpleNamespace( - name=name, source="global", config_class=Config, run_fn=run - ) + return SimpleNamespace(name=name, source="global", config_class=Config, run_fn=run) def test_store_run_stamps_the_source_when_the_routine_forgot_it(reports_dir): diff --git a/utils/auth.py b/utils/auth.py index 5759c71b..cdb7ddb3 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -4,11 +4,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes -from config_manager import ( - ServerPermission, - UserRole, - get_config_manager, -) +from config_manager import ServerPermission, UserRole, get_config_manager logger = logging.getLogger(__name__) diff --git a/utils/deeplink.py b/utils/deeplink.py index f949e9ba..2fdaae6c 100644 --- a/utils/deeplink.py +++ b/utils/deeplink.py @@ -25,11 +25,11 @@ def decode_deeplink(encoded: str) -> Tuple[Optional[dict], Optional[str]]: """ try: # Add padding if needed and decode base64 - padded = encoded + '=' * (4 - len(encoded) % 4) - decoded = base64.urlsafe_b64decode(padded).decode('utf-8') + padded = encoded + "=" * (4 - len(encoded) % 4) + decoded = base64.urlsafe_b64decode(padded).decode("utf-8") # Split pipe-delimited fields - parts = decoded.split('|') + parts = decoded.split("|") if len(parts) != 5: return None, f"Invalid payload format (expected 5 fields, got {len(parts)})" @@ -42,11 +42,11 @@ def decode_deeplink(encoded: str) -> Tuple[Optional[dict], Optional[str]]: return None, "Invalid port number" return { - 'name': name, - 'host': host, - 'port': port, - 'username': username, - 'password': password, + "name": name, + "host": host, + "port": port, + "username": username, + "password": password, }, None except Exception as e: diff --git a/utils/transcribe.py b/utils/transcribe.py index 2af72f1f..31e668d9 100644 --- a/utils/transcribe.py +++ b/utils/transcribe.py @@ -24,7 +24,9 @@ def _get_model(model_size: str = DEFAULT_MODEL): from faster_whisper import WhisperModel log.info("Loading Whisper model '%s' ...", model_size) - _models[model_size] = WhisperModel(model_size, device="cpu", compute_type="int8") + _models[model_size] = WhisperModel( + model_size, device="cpu", compute_type="int8" + ) log.info("Whisper model loaded: %s", model_size) return _models[model_size] @@ -74,6 +76,9 @@ def _transcribe_sync( text = " ".join(seg.text.strip() for seg in segments) log.info( "Transcribed %.1fs audio → %d chars (lang=%s, model=%s)", - info.duration, len(text), info.language, model_size, + info.duration, + len(text), + info.language, + model_size, ) return text diff --git a/utils/updater.py b/utils/updater.py index fc3c661d..e5e9df78 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -20,11 +20,15 @@ logger = logging.getLogger(__name__) # How often to check for updates (seconds) -UPDATE_CHECK_INTERVAL = int(os.environ.get("UPDATE_CHECK_INTERVAL", "3600")) # 1h default +UPDATE_CHECK_INTERVAL = int( + os.environ.get("UPDATE_CHECK_INTERVAL", "3600") +) # 1h default CONDOR_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HUMMINGBOT_API_DIR = os.path.normpath( - os.environ.get("HUMMINGBOT_API_DIR", os.path.join(CONDOR_DIR, "..", "hummingbot-api")) + os.environ.get( + "HUMMINGBOT_API_DIR", os.path.join(CONDOR_DIR, "..", "hummingbot-api") + ) ) FRONTEND_DIR = os.path.join(CONDOR_DIR, "frontend") @@ -124,7 +128,9 @@ async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: # Get local and remote commits _, local = await _run_git("rev-parse", "--short", "HEAD", repo_dir=repo_dir) - _, remote = await _run_git("rev-parse", "--short", f"origin/{branch}", repo_dir=repo_dir) + _, remote = await _run_git( + "rev-parse", "--short", f"origin/{branch}", repo_dir=repo_dir + ) result["local_commit"] = local result["remote_commit"] = remote @@ -142,7 +148,10 @@ async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: if commits_behind > 0: # Get log of new commits _, log = await _run_git( - "log", "--oneline", f"HEAD..origin/{branch}", "--max-count=10", + "log", + "--oneline", + f"HEAD..origin/{branch}", + "--max-count=10", repo_dir=repo_dir, ) result["commit_log"] = log @@ -165,7 +174,10 @@ async def pull_updates(repo_dir: str = CONDOR_DIR) -> tuple[bool, str]: # Check for uncommitted changes rc, status = await _run_git("status", "--porcelain", repo_dir=repo_dir) if status: - return False, "Cannot update: there are uncommitted changes. Please commit or stash first." + return ( + False, + "Cannot update: there are uncommitted changes. Please commit or stash first.", + ) # Pull rc, output = await _run_git("pull", "origin", branch, repo_dir=repo_dir) @@ -196,7 +208,11 @@ async def paths_changed( if old_commit == new_commit: return False rc, out = await _run_git( - "diff", "--name-only", f"{old_commit}..{new_commit}", "--", *paths, + "diff", + "--name-only", + f"{old_commit}..{new_commit}", + "--", + *paths, repo_dir=repo_dir, ) if rc != 0: @@ -303,15 +319,19 @@ def hb_api_available() -> bool: return os.path.isdir(HUMMINGBOT_API_DIR) -async def get_docker_container_info(container_name: str = "hummingbot-api") -> dict | None: +async def get_docker_container_info( + container_name: str = "hummingbot-api", +) -> dict | None: """ Inspect a Docker container and return basic info. Returns {"status", "started_at", "image"} or None if unavailable. """ rc, output = await _run_cmd( - "docker", "inspect", - "--format", '{{.State.Status}}|{{.State.StartedAt}}|{{.Config.Image}}', + "docker", + "inspect", + "--format", + "{{.State.Status}}|{{.State.StartedAt}}|{{.Config.Image}}", container_name, timeout=30, ) @@ -367,7 +387,9 @@ async def update_hb_api() -> tuple[bool, str]: # Docker compose build rc, output = await _run_cmd( - "docker", "compose", "build", + "docker", + "compose", + "build", cwd=HUMMINGBOT_API_DIR, timeout=DOCKER_TIMEOUT, ) @@ -376,7 +398,10 @@ async def update_hb_api() -> tuple[bool, str]: # Docker compose up -d rc, output = await _run_cmd( - "docker", "compose", "up", "-d", + "docker", + "compose", + "up", + "-d", cwd=HUMMINGBOT_API_DIR, timeout=DOCKER_TIMEOUT, ) From 4fbd32203e95f81794f021d91763c06c70efa314 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:01:48 +0300 Subject: [PATCH 009/116] Skip the format sweep in git blame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep rewrote 85 files without changing a line of logic, so it sits on top of every blame it touched. The repo had no `.git-blame-ignore-revs`; this adds one holding that single revision. Git does not read the file automatically — it is per-clone opt-in, so the header carries the `git config blame.ignoreRevsFile` line rather than assuming anyone already ran it. GitHub's blame view honors the file without configuration. READ-133 --- .git-blame-ignore-revs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..ac49978c --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Revisions listed here are skipped by `git blame`. +# Enable locally with: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# (style) mechanical format sweep: isort then black over the Python tree (READ-133) +3fa941fa3e49fb647b94916b92d11e481e7b7fdd From 4c43a253454ae6b73ea61c6aebb751feca151746 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:06:28 +0300 Subject: [PATCH 010/116] (fix) survive an executor whose config is null instead of 502ing the listing (CORR-131) get_executor_type resolved the config with executor.get("config", executor), whose default only fires when the key is absent. The backend emits rows with the key present and explicitly null, so config became None and the first source.get(...) raised AttributeError. The helper runs while building every display row -- REST listing, WS broadcast and the agents rollup all go through it -- so one malformed executor took down the whole page rather than degrading to an unknown type for that single row. Guard the resolution with the same isinstance check build_executor_row already uses, falling back to the executor itself so a row carrying its fields at the top level (and the start_price/stop_loss shape inference) resolves exactly as before. --- condor/fetchers/executors.py | 13 ++++- tests/test_executor_type_null_config.py | 71 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/test_executor_type_null_config.py diff --git a/condor/fetchers/executors.py b/condor/fetchers/executors.py index e47422a1..2e37bc19 100644 --- a/condor/fetchers/executors.py +++ b/condor/fetchers/executors.py @@ -42,8 +42,19 @@ def get_executor_type(executor: Dict[str, Any]) -> str: """Determine executor type from its data. Returns the executor type label (e.g. 'grid', 'position', 'order', 'dca', 'lp'). + + Never raises: this runs while building every display row, so an executor + whose ``config`` is explicitly ``null`` (a shape the backend does emit) must + degrade to ``unknown`` for that one row instead of taking down the whole + listing. """ - config = executor.get("config", executor) + # ``config`` is often present *and* null, so a ``dict.get`` default is not + # enough -- it only covers the absent key. Same isinstance guard as + # :func:`build_executor_row`, falling back to the executor itself so a row + # that carries its fields at the top level still resolves as before. + config = executor.get("config") + if not isinstance(config, dict): + config = executor for source in (config, executor): ex_type = source.get("type", "") or source.get("executor_type", "") if isinstance(ex_type, str) and ex_type: diff --git a/tests/test_executor_type_null_config.py b/tests/test_executor_type_null_config.py new file mode 100644 index 00000000..d16297f6 --- /dev/null +++ b/tests/test_executor_type_null_config.py @@ -0,0 +1,71 @@ +"""Tests for CORR-131: a null ``config`` must not take down the executor listing. + +``get_executor_type`` resolved the config with ``executor.get("config", executor)``, +whose default only fires when the key is *absent*. The backend does emit rows with +the key present and explicitly ``null``, which made ``config`` ``None`` and raised +``AttributeError`` on the first ``source.get(...)``. Since the helper runs while +building every display row (REST listing, WS broadcast, agents rollup), one +malformed executor 502'd the whole listing instead of degrading that single row. +""" + +from condor.fetchers.executors import build_executor_row, get_executor_type +from condor.web.models import ExecutorInfo +from condor.web.routes.executors import _offset_page + +# ── The crash this item closes ── + + +def test_null_config_returns_a_label_instead_of_raising(): + assert get_executor_type({"config": None}) == "unknown" + + +def test_non_dict_config_returns_a_label_instead_of_raising(): + assert get_executor_type({"config": "not-a-dict"}) == "unknown" + assert get_executor_type({"config": []}) == "unknown" + + +def test_null_config_still_resolves_a_top_level_type(): + """The fallback to the executor itself survives the null-safety guard.""" + assert get_executor_type({"config": None, "type": "PositionExecutor"}) == "position" + assert ( + get_executor_type({"config": None, "executor_type": "GridExecutor"}) == "grid" + ) + + +# ── The blast radius: one bad row must not sink the page ── + + +def test_null_config_row_survives_the_display_row_transform(): + row = build_executor_row({"id": "e1", "config": None}) + + assert row["id"] == "e1" + assert row["config"] == {} + assert ExecutorInfo.from_raw({"id": "e1", "config": None}).type == "unknown" + + +def test_one_null_config_row_does_not_break_the_rest_of_the_listing(): + """The 502: the listing loop raised on the bad row before reaching the good ones.""" + rows = [ + {"id": "good-1", "config": {"type": "PositionExecutor"}}, + {"id": "bad", "config": None}, + {"id": "good-2", "config": {"type": "GridExecutor"}}, + ] + + items = _offset_page(rows, 0, 10)["executors"] + + assert [i.id for i in items] == ["good-1", "bad", "good-2"] + assert [i.type for i in items] == ["position", "unknown", "grid"] + + +# ── Well-formed executors resolve exactly as before ── + + +def test_type_resolution_is_unchanged_for_well_formed_executors(): + assert get_executor_type({"config": {"type": "PositionExecutor"}}) == "position" + assert get_executor_type({"config": {"executor_type": "DCAExecutor"}}) == "dca" + assert get_executor_type({"type": "OrderExecutor"}) == "order" + # Shape inference, which reads the executor itself when there is no config key. + assert get_executor_type({"start_price": 1, "end_price": 2}) == "grid" + assert get_executor_type({"config": {"stop_loss": 0.01}}) == "position" + assert get_executor_type({"trailing_stop": {}}) == "position" + assert get_executor_type({}) == "unknown" From d57d2053cde6ca42d037d18e0811a0dcf2794a7e Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:11:21 +0300 Subject: [PATCH 011/116] (fix) fail closed when the executor pair validation itself breaks (CORR-132) CORR-124 made validate_trading_pair fail closed, but the grid and position wizards wrapped the whole validation block in a bare except Exception whose fall-through still assigned and persisted the pair. Anything raised around the validator -- the executors client lookup, the get_correct_pair_format fallback read -- therefore landed an unvalidated pair in the executor config with only a log warning, reopening the hole one refactor away. Both panels now resolve a verdict inside the try and gate the write outside it: a raised exception sets is_valid=False with a cause-naming message, so it reaches the same suggestions screen an explicitly invalid pair does and never touches the config. The stale "Allow through if validation fails" comment is gone. The block stays identical in both files. Users can now be blocked where they previously slipped through -- that is the point of the item; no existing test depended on the fail-open path. --- handlers/executors/grid.py | 38 ++-- handlers/executors/position.py | 37 ++-- ...st_executor_pair_validation_fail_closed.py | 190 ++++++++++++++++++ 3 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 tests/test_executor_pair_validation_fail_closed.py diff --git a/handlers/executors/grid.py b/handlers/executors/grid.py index 343bf36a..3c5b22e0 100644 --- a/handlers/executors/grid.py +++ b/handlers/executors/grid.py @@ -446,27 +446,35 @@ async def handle_pair_input( context.user_data, client, connector, pair ) - if not is_valid: - await _show_pair_suggestions( - update, context, pair, error_msg, suggestions, connector - ) - return - - # Use the correct pair format returned by validation - if correct_pair: - pair = correct_pair - else: + if is_valid and not correct_pair: # Fallback: Get correctly formatted pair from trading rules trading_rules = await get_trading_rules( context.user_data, client, connector ) - fallback_pair = get_correct_pair_format(trading_rules, pair) - if fallback_pair: - pair = fallback_pair + correct_pair = get_correct_pair_format(trading_rules, pair) except Exception as e: - logger.warning(f"Could not validate trading pair: {e}") - # Allow through if validation fails (e.g. no trading rules) + # Fail closed: anything that breaks around the validator leaves the pair + # unvalidated, so it must not reach the config. The user gets the same + # surface as an explicitly invalid pair. + logger.warning(f"Could not validate trading pair '{pair}' on {connector}: {e}") + is_valid = False + error_msg = ( + f"Could not reach {connector} to validate the pair. " + "Try again in a moment." + ) + suggestions = [] + correct_pair = None + + if not is_valid: + await _show_pair_suggestions( + update, context, pair, error_msg, suggestions, connector + ) + return + + # Use the correct pair format resolved during validation + if correct_pair: + pair = correct_pair config["trading_pair"] = pair set_executor_config(context, config) diff --git a/handlers/executors/position.py b/handlers/executors/position.py index ef4ee23c..82726ce6 100644 --- a/handlers/executors/position.py +++ b/handlers/executors/position.py @@ -440,26 +440,35 @@ async def handle_pair_input( context.user_data, client, connector, pair ) - if not is_valid: - await _show_pair_suggestions( - update, context, pair, error_msg, suggestions, connector - ) - return - - # Use the correct pair format returned by validation - if correct_pair: - pair = correct_pair - else: + if is_valid and not correct_pair: # Fallback: Get correctly formatted pair from trading rules trading_rules = await get_trading_rules( context.user_data, client, connector ) - fallback_pair = get_correct_pair_format(trading_rules, pair) - if fallback_pair: - pair = fallback_pair + correct_pair = get_correct_pair_format(trading_rules, pair) except Exception as e: - logger.warning(f"Could not validate trading pair: {e}") + # Fail closed: anything that breaks around the validator leaves the pair + # unvalidated, so it must not reach the config. The user gets the same + # surface as an explicitly invalid pair. + logger.warning(f"Could not validate trading pair '{pair}' on {connector}: {e}") + is_valid = False + error_msg = ( + f"Could not reach {connector} to validate the pair. " + "Try again in a moment." + ) + suggestions = [] + correct_pair = None + + if not is_valid: + await _show_pair_suggestions( + update, context, pair, error_msg, suggestions, connector + ) + return + + # Use the correct pair format resolved during validation + if correct_pair: + pair = correct_pair config["trading_pair"] = pair set_executor_config(context, config) diff --git a/tests/test_executor_pair_validation_fail_closed.py b/tests/test_executor_pair_validation_fail_closed.py new file mode 100644 index 00000000..3dfe13cb --- /dev/null +++ b/tests/test_executor_pair_validation_fail_closed.py @@ -0,0 +1,190 @@ +"""Tests for CORR-132: the executor panels must fail closed around the validator. + +[[CORR-124]] made ``validate_trading_pair`` itself fail closed, but the grid and +position wizards still wrapped the whole validation block in a bare +``except Exception`` whose fall-through path assigned and persisted the pair +anyway. Anything raised *around* the validator — the API client lookup, the +``get_correct_pair_format`` fallback read — therefore still landed an +unvalidated pair in the executor config with only a log warning. + +Both panels now treat a raised exception exactly like an invalid pair: nothing +is persisted, and the user lands on the same suggestions screen. The two causes +stay distinguishable in the message the user reads. +""" + +import asyncio +from types import SimpleNamespace + +import pytest + +from handlers.executors import grid, position + +RULES = {"BTC-USDT": {"min_order_size": 1}} + +# Each panel duplicates the block verbatim, so every test runs against both. +PANELS = [ + pytest.param((grid, "show_step_2_combined"), id="grid"), + pytest.param((position, "show_step_2_config"), id="position"), +] + +both_panels = pytest.mark.parametrize("panel", PANELS, indirect=True) + + +def _boom(): + raise RuntimeError("502 Bad Gateway") + + +class _Panel: + """Drives one wizard's ``handle_pair_input`` with its collaborators stubbed.""" + + def __init__(self, module, step_2_name, monkeypatch): + self.module = module + self.monkeypatch = monkeypatch + self.shown = None + self.reached_step_2 = False + self.context = SimpleNamespace( + user_data={ + "executor_config_params": {"connector_name": "binance_perpetual"} + } + ) + self.update = SimpleNamespace( + effective_chat=SimpleNamespace(id=1), message=None, callback_query=None + ) + + async def show_pair_suggestions( + update, context, input_pair, error_msg, suggestions, connector + ): + self.shown = { + "pair": input_pair, + "error_msg": error_msg, + "suggestions": suggestions, + "connector": connector, + } + + async def step_2(update, context): + self.reached_step_2 = True + + self._patch("_show_pair_suggestions", show_pair_suggestions) + self._patch(step_2_name, step_2) + self.set_client(lambda: (object(), None)) + self.set_validator(lambda: (True, None, [], "BTC-USDT")) + self.set_trading_rules(lambda: RULES) + + def _patch(self, name, fn): + self.monkeypatch.setattr(self.module, name, fn) + + def set_client(self, produce): + async def get_executors_client(chat_id, user_data): + return produce() + + self._patch("get_executors_client", get_executors_client) + + def set_validator(self, produce): + async def validate_trading_pair(user_data, client, connector, pair): + return produce() + + self._patch("validate_trading_pair", validate_trading_pair) + + def set_trading_rules(self, produce): + async def get_trading_rules(user_data, client, connector): + return produce() + + self._patch("get_trading_rules", get_trading_rules) + + def run(self, pair): + asyncio.run(self.module.handle_pair_input(self.update, self.context, pair)) + + @property + def config(self): + return self.context.user_data["executor_config_params"] + + +@pytest.fixture +def panel(request, monkeypatch): + module, step_2_name = request.param + return _Panel(module, step_2_name, monkeypatch) + + +@both_panels +def test_a_raising_validator_does_not_persist_the_pair(panel): + """The whole point: an exception must not wave an unvalidated pair through.""" + panel.set_validator(_boom) + + panel.run("DOGE-USDT") + + assert "trading_pair" not in panel.config + assert panel.reached_step_2 is False + assert panel.shown is not None + + +@both_panels +def test_a_failure_before_the_validator_does_not_persist_the_pair(panel): + """The client lookup is inside the block, so its failure must fail closed too.""" + panel.set_client(_boom) + + panel.run("DOGE-USDT") + + assert "trading_pair" not in panel.config + assert panel.reached_step_2 is False + assert panel.shown is not None + + +@both_panels +def test_a_raising_pair_format_fallback_does_not_persist_the_pair(panel): + """The fallback read runs after a valid verdict, and still gates the write.""" + panel.set_validator(lambda: (True, None, [], None)) + panel.set_trading_rules(_boom) + + panel.run("BTC-USDT") + + assert "trading_pair" not in panel.config + assert panel.reached_step_2 is False + assert panel.shown is not None + + +@both_panels +def test_a_crash_and_an_invalid_pair_share_the_surface_but_not_the_cause(panel): + """Same screen for both, but the user can still tell them apart.""" + panel.set_validator(lambda: (False, "DOGE-USDT not found", ["DOGE-USDC"], None)) + panel.run("DOGE-USDT") + invalid = panel.shown + + panel.shown = None + panel.set_validator(_boom) + panel.run("DOGE-USDT") + crashed = panel.shown + + # Same surface: neither persists, both land on the suggestions screen. + assert "trading_pair" not in panel.config + assert invalid is not None + assert crashed is not None + + # Different cause: "that pair is wrong" vs "we could not check". + assert invalid["error_msg"] == "DOGE-USDT not found" + assert invalid["suggestions"] == ["DOGE-USDC"] + assert "could not reach" in crashed["error_msg"].lower() + assert "binance_perpetual" in crashed["error_msg"] + assert crashed["suggestions"] == [] + + +@both_panels +def test_a_validated_pair_is_still_persisted_in_the_exchange_format(panel): + """Fail-closed must not block the happy path.""" + panel.set_validator(lambda: (True, None, [], "BTC-USDT")) + + panel.run("btc/usdt") + + assert panel.config["trading_pair"] == "BTC-USDT" + assert panel.reached_step_2 is True + assert panel.shown is None + + +@both_panels +def test_the_pair_format_fallback_still_applies_when_validation_returns_none(panel): + """A valid verdict without a formatted pair still goes through the fallback.""" + panel.set_validator(lambda: (True, None, [], None)) + + panel.run("BTC-USDT") + + assert panel.config["trading_pair"] == "BTC-USDT" + assert panel.reached_step_2 is True From 138dab7756697aac87a3681209bfcf26142736a9 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:21:47 +0300 Subject: [PATCH 012/116] (sec) four more route modules fail without naming the backend (SEC-130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `str(e)` on an aiohttp client exception embeds the backend's own URL, so raising it as an HTTPException detail publishes the internal host and port to anyone who can provoke a backend blip. SEC-116 removed that from the executor mutations and SEC-126 from the executor reads; bots, settings, market and controller_performance were still doing it at all 34 of their backend call boundaries. The mapping that executors.py had kept module-private moves to condor/web/routes/_errors.py as `upstream_error`, so there is one implementation rather than five. It still describes the failure with `describe_executor_error` — no second sanitizer — and keeps the same contract: an upstream 4xx is the caller's own bad request and stays 400, anything else is 502. The settings endpoints previously answered 500 for both; they now match the rest. Every converted site logs the exception first. The address belongs in the server log, where an operator needs it; only the client loses it. Left alone: the `detail=str(e)` sites that catch a named domain exception (a rejected identifier, an unparseable provider URL) rather than a bare `Exception`. Those messages are our own text about the caller's own input and carry no address — sessions.py and agents.py are untouched for the same reason. --- condor/web/routes/_errors.py | 40 +++ condor/web/routes/bots.py | 80 +++++- condor/web/routes/controller_performance.py | 9 +- condor/web/routes/executors.py | 43 +-- condor/web/routes/market.py | 39 ++- condor/web/routes/settings.py | 46 ++- tests/test_route_upstream_errors.py | 297 ++++++++++++++++++++ 7 files changed, 485 insertions(+), 69 deletions(-) create mode 100644 condor/web/routes/_errors.py create mode 100644 tests/test_route_upstream_errors.py diff --git a/condor/web/routes/_errors.py b/condor/web/routes/_errors.py new file mode 100644 index 00000000..0bdd4c2c --- /dev/null +++ b/condor/web/routes/_errors.py @@ -0,0 +1,40 @@ +"""One mapping from a failed upstream call to a response that leaks nothing. + +``str(exc)`` on an ``aiohttp`` client exception embeds the backend's own URL, so +handing it to a browser as an ``HTTPException`` detail publishes the internal +host and port to anyone who can provoke a backend blip — a timeout, a refused +connection, an upstream 5xx. SEC-116 established the remedy for the executor +mutations and SEC-126 extended it to the executor reads; this module is that +same remedy lifted out of ``routes/executors.py`` so every route module shares +one implementation instead of growing a second sanitizer. + +The description itself still comes from :func:`condor.fetchers.describe_executor_error`, +which reads the safe pieces off the exception's attributes (``status`` is the +code the API answered with, ``message`` is the API's own ``detail``) and +collapses a transport failure — which has neither — to a generic line. +""" + +from __future__ import annotations + +from fastapi import HTTPException + +from condor.fetchers.executors import describe_executor_error + +__all__ = ["upstream_error"] + + +def upstream_error(action: str, exc: Exception) -> HTTPException: + """Map a failed backend call to an ``HTTPException`` that leaks nothing. + + An upstream 4xx is the caller's own bad request and stays a 400; anything + else — an upstream 5xx, a timeout, a refused connection — is this gateway + failing to reach its backend, so 502. The detail carries the API's message + but never the raw exception. + + Callers log the exception before raising: the address belongs in the server + log, where an operator needs it, not in the response, where a trader on a + shared server would read it. + """ + status, message = describe_executor_error(exc) + code = 400 if status is not None and 400 <= status < 500 else 502 + return HTTPException(status_code=code, detail=f"{action}: {message}") diff --git a/condor/web/routes/bots.py b/condor/web/routes/bots.py index e113526d..17b27b1c 100644 --- a/condor/web/routes/bots.py +++ b/condor/web/routes/bots.py @@ -22,6 +22,7 @@ DeployBotRequest, WebUser, ) +from condor.web.routes._errors import upstream_error from config_manager import get_config_manager from handlers.bots._shared import clean_config_for_save @@ -405,7 +406,8 @@ async def get_bot(name: str, bot_id: str, user: WebUser = Depends(get_current_us try: result = await client.bot_orchestration.get_bot_status(bot_id) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to fetch status for bot '%s' on '%s'", bot_id, name) + raise upstream_error("Failed to fetch bot status", e) if not isinstance(result, dict): raise HTTPException(status_code=404, detail="Bot not found") @@ -511,7 +513,10 @@ async def get_controller_config( try: result = await client.controllers.get_controller_config(config_id) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch controller config '%s' from '%s'", config_id, name + ) + raise upstream_error("Failed to fetch controller config", e) if not isinstance(result, dict): raise HTTPException(status_code=404, detail="Config not found") @@ -584,7 +589,10 @@ async def update_controller_config( except HTTPException: raise except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to update controller config '%s' on '%s'", config_id, name + ) + raise upstream_error("Failed to save controller config", e) return {"updated": True, "config_id": config_id, "result": result} @@ -610,7 +618,13 @@ async def get_controller_source( controller_type, controller_name ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch controller source '%s/%s' from '%s'", + controller_type, + controller_name, + name, + ) + raise upstream_error("Failed to fetch controller source", e) if isinstance(result, str): source = result @@ -656,7 +670,13 @@ async def update_controller_source( controller_type, controller_name, {"content": source} ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to update controller source '%s/%s' on '%s'", + controller_type, + controller_name, + name, + ) + raise upstream_error("Failed to save controller source", e) return {"updated": True, "result": result} @@ -681,7 +701,13 @@ async def get_controller_config_template( controller_type, controller_name ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch config template for '%s/%s' from '%s'", + controller_type, + controller_name, + name, + ) + raise upstream_error("Failed to fetch controller config template", e) if not result: raise HTTPException(status_code=404, detail="Template not found") @@ -730,7 +756,10 @@ async def create_controller_config( config_id, clean_body ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to create controller config '%s' on '%s'", config_id, name + ) + raise upstream_error("Failed to save controller config", e) return {"created": True, "config_id": config_id, "result": result} @@ -750,7 +779,10 @@ async def delete_controller_config( try: result = await client.controllers.delete_controller_config(config_id) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to delete controller config '%s' from '%s'", config_id, name + ) + raise upstream_error("Failed to delete controller config", e) return {"deleted": True, "config_id": config_id, "result": result} @@ -773,7 +805,13 @@ async def delete_controller( controller_type, controller_name ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to delete controller '%s/%s' from '%s'", + controller_type, + controller_name, + name, + ) + raise upstream_error("Failed to delete controller", e) return { "deleted": True, @@ -806,7 +844,8 @@ async def deploy_bot_endpoint( max_controller_drawdown_quote=body.max_controller_drawdown_quote, ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to deploy bot '%s' on '%s'", body.bot_name, name) + raise upstream_error("Failed to deploy bot", e) return result @@ -834,7 +873,8 @@ async def stop_bot_endpoint( ) except Exception as e: clear_bot_stopping(name, bot_name) - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to stop bot '%s' on '%s'", bot_name, name) + raise upstream_error("Failed to stop bot", e) return result @@ -865,7 +905,10 @@ async def stop_controllers_endpoint( controller_names=body.controller_names, ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to stop controllers on bot '%s' of '%s'", bot_name, name + ) + raise upstream_error("Failed to stop controllers", e) return result @@ -893,7 +936,10 @@ async def start_controllers_endpoint( controller_names=body.controller_names, ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to start controllers on bot '%s' of '%s'", bot_name, name + ) + raise upstream_error("Failed to start controllers", e) return result @@ -934,7 +980,13 @@ async def update_bot_controller_config_endpoint( except HTTPException: raise except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to update controller config '%s' on bot '%s' of '%s'", + config_id, + bot_name, + name, + ) + raise upstream_error("Failed to save controller config", e) return { "updated": True, diff --git a/condor/web/routes/controller_performance.py b/condor/web/routes/controller_performance.py index 2150a940..eaee3ea9 100644 --- a/condor/web/routes/controller_performance.py +++ b/condor/web/routes/controller_performance.py @@ -16,6 +16,7 @@ ControllerPerformanceSnapshot, WebUser, ) +from condor.web.routes._errors import upstream_error from config_manager import get_config_manager logger = logging.getLogger(__name__) @@ -136,8 +137,8 @@ async def _fetch_perf() -> dict[str, dict]: try: result, perf_by_bot = await asyncio.gather(_fetch_runs(), _fetch_perf()) except Exception as e: - logger.warning("Failed to fetch bot runs from '%s': %s", name, e) - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to fetch bot runs from '%s'", name) + raise upstream_error("Failed to fetch bot runs", e) runs_list = _extract_runs_list(result) @@ -165,8 +166,8 @@ async def delete_bot_run( try: result = await client.bot_orchestration.delete_bot_run(bot_run_id) except Exception as e: - logger.warning("Failed to delete bot run %d from '%s': %s", bot_run_id, name, e) - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to delete bot run %d from '%s'", bot_run_id, name) + raise upstream_error("Failed to delete bot run", e) return {"deleted": True, "bot_run_id": bot_run_id, "result": result} diff --git a/condor/web/routes/executors.py b/condor/web/routes/executors.py index b81d9d34..4ab74935 100644 --- a/condor/web/routes/executors.py +++ b/condor/web/routes/executors.py @@ -8,11 +8,7 @@ logger = logging.getLogger(__name__) -from condor.fetchers.executors import ( - EXECUTORS_POLL_MAX, - MAX_EXECUTORS_FETCH, - describe_executor_error, -) +from condor.fetchers.executors import EXECUTORS_POLL_MAX, MAX_EXECUTORS_FETCH from condor.fetchers.executors import extract_executors_list as _extract_executors_list from condor.fetchers.executors import fetch_all_executors, summarize_executors_by_quote from condor.web.auth import get_current_user @@ -22,6 +18,7 @@ ExecutorPeriodSummary, WebUser, ) +from condor.web.routes._errors import upstream_error from config_manager import get_config_manager router = APIRouter(tags=["executors"]) @@ -45,24 +42,6 @@ _summary_cache: dict[tuple[str, str], tuple[float, ExecutorPeriodSummary]] = {} -def _executor_error(action: str, exc: Exception) -> HTTPException: - """Map an executor call failure to an HTTPException that leaks nothing. - - An upstream 4xx is the caller's own bad request and stays a 400; anything - else — an upstream 5xx, a timeout, a refused connection — is the gateway - failing, so 502. The detail carries the API's message but never the raw - exception, whose string embeds the backend URL and port. - - Reads go through this too, not only mutations: the listing endpoints are the - dashboard's hottest path, so any backend blip is the most reachable way for - that address to reach a browser. Callers log the exception before raising — - the address belongs in the server log, not in the response. - """ - status, message = describe_executor_error(exc) - code = 400 if status is not None and 400 <= status < 500 else 502 - return HTTPException(status_code=code, detail=f"{action}: {message}") - - @router.get("/servers/{name}/executors", response_model=list[ExecutorInfo]) async def list_executors( name: str, @@ -106,7 +85,7 @@ async def list_executors( result = executors_list except Exception as e: logger.exception("Failed to fetch executors for server %s", name) - raise _executor_error("Failed to fetch executors", e) + raise upstream_error("Failed to fetch executors", e) else: try: result = await get_server_data_service().get_or_fetch( @@ -114,7 +93,7 @@ async def list_executors( ) except Exception as e: logger.exception("Failed to fetch executors for server %s", name) - raise _executor_error("Failed to fetch executors", e) + raise upstream_error("Failed to fetch executors", e) if result is None: raise HTTPException(status_code=502, detail="Failed to fetch executors") @@ -198,7 +177,7 @@ async def list_executors_page( rows = await fetch_all_executors(client, max_items=offset + limit + 1) except Exception as e: logger.exception("Failed to page executors for server %s", name) - raise _executor_error("Failed to fetch executors", e) + raise upstream_error("Failed to fetch executors", e) return _offset_page(rows, offset, limit) # Cold cache on the first page: fall through to opaque API cursors, # which page the rest of the scroll in one request each. @@ -220,7 +199,7 @@ async def list_executors_page( result = await client.executors.search_executors(**kwargs) except Exception as e: logger.exception("Failed to page executors for server %s", name) - raise _executor_error("Failed to fetch executors", e) + raise upstream_error("Failed to fetch executors", e) page = _extract_executors_list(result) next_cursor = None @@ -321,7 +300,7 @@ async def executors_summary( executors = await fetch_all_executors(client) except Exception as e: logger.exception("Failed to summarize executors for server %s", name) - raise _executor_error("Failed to fetch executors", e) + raise upstream_error("Failed to fetch executors", e) summary = await _usd_summary( name, period, summarize_executors_by_quote(executors, now - window) @@ -350,7 +329,7 @@ async def create_executor_endpoint( try: result = await create_executor(client, config, account_name=body.account_name) except Exception as e: - raise _executor_error("Failed to create executor", e) + raise upstream_error("Failed to create executor", e) executor_id = "" if isinstance(result, dict): executor_id = str(result.get("executor_id") or result.get("id") or "") @@ -375,7 +354,7 @@ async def stop_executor_endpoint( try: result = await stop_executor(client, executor_id, keep_position=keep_position) except Exception as e: - raise _executor_error("Failed to stop executor", e) + raise upstream_error("Failed to stop executor", e) return {"status": "ok", "result": result} @@ -393,7 +372,7 @@ async def get_positions_held( result = await client.executors.get_positions_summary() except Exception as e: logger.exception("Failed to fetch held positions for server %s", name) - raise _executor_error("Failed to fetch positions", e) + raise upstream_error("Failed to fetch positions", e) # Normalize: extract positions list from various shapes if isinstance(result, dict): @@ -436,5 +415,5 @@ async def clear_position_held( ) except Exception as e: logger.exception("Failed to clear held position on server %s", name) - raise _executor_error("Failed to clear position", e) + raise upstream_error("Failed to clear position", e) return {"status": "ok", "result": result} diff --git a/condor/web/routes/market.py b/condor/web/routes/market.py index 680f0eb3..032bc931 100644 --- a/condor/web/routes/market.py +++ b/condor/web/routes/market.py @@ -42,6 +42,7 @@ def _candle_cache_put(key: tuple, value: list, now: float) -> None: TradingRulesResponse, WebUser, ) +from condor.web.routes._errors import upstream_error router = APIRouter(tags=["market"]) @@ -81,7 +82,8 @@ async def get_connectors(name: str, user: WebUser = Depends(get_current_user)): name, ServerDataType.CANDLE_CONNECTORS ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to fetch candle connectors from '%s'", name) + raise upstream_error("Failed to fetch connectors", e) return result @@ -99,7 +101,8 @@ async def get_connected_exchanges(name: str, user: WebUser = Depends(get_current name, ServerDataType.CONNECTORS ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to fetch connected exchanges from '%s'", name) + raise upstream_error("Failed to fetch connectors", e) return result or [] @@ -220,7 +223,10 @@ async def get_price( trading_pair=trading_pair, ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch price for %s on %s of '%s'", trading_pair, connector, name + ) + raise upstream_error("Failed to fetch price", e) if result is None: raise HTTPException(status_code=502, detail="Failed to fetch price") @@ -260,7 +266,8 @@ async def get_rates( try: rates = await resolve(name, trading_pairs, connector=body.get("connector")) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to resolve rates on '%s'", name) + raise upstream_error("Failed to fetch rates", e) return RatesResponse(rates=rates) @@ -290,7 +297,10 @@ async def get_trading_rules( name, ServerDataType.TRADING_RULES, connector_name=connector ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch trading rules for '%s' on '%s'", connector, name + ) + raise upstream_error("Failed to fetch trading rules", e) if not isinstance(result, dict): return TradingRulesResponse(connector=connector, rules=[]) @@ -328,7 +338,8 @@ async def get_tickers( try: result = await get_connector_tickers(name, connector) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception("Failed to fetch tickers for '%s' on '%s'", connector, name) + raise upstream_error("Failed to fetch tickers", e) if not isinstance(result, dict): return TickersResponse(connector=connector, tickers=[]) @@ -366,7 +377,13 @@ async def get_order_book( connector_name=connector, trading_pair=trading_pair ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch order book for %s on %s of '%s'", + trading_pair, + connector, + name, + ) + raise upstream_error("Failed to fetch order book", e) bids = [] asks = [] @@ -505,7 +522,13 @@ async def get_candles( connector, trading_pair, interval, limit ) except Exception as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.exception( + "Failed to fetch candles for %s on %s of '%s'", + trading_pair, + connector, + name, + ) + raise upstream_error("Failed to fetch candles", e) candles_raw = ( result diff --git a/condor/web/routes/settings.py b/condor/web/routes/settings.py index b7408a5f..ba8e3676 100644 --- a/condor/web/routes/settings.py +++ b/condor/web/routes/settings.py @@ -16,6 +16,7 @@ UpdateServerRequest, WebUser, ) +from condor.web.routes._errors import upstream_error from config_manager import ServerPermission, get_config_manager logger = logging.getLogger(__name__) @@ -35,8 +36,15 @@ def _require_owner(cm, user_id: int, server_name: str): async def _get_client(cm, server_name: str): try: return await cm.get_client(server_name) - except Exception as e: + except ValueError as e: + # The config manager's own rejection ("no such server"). Its text names + # nothing the caller did not already type, so it can be shown as-is. raise HTTPException(status_code=502, detail=f"Cannot connect to server: {e}") + except Exception as e: + # Anything else came off the wire, and its string carries the backend + # address — log it, show the caller only the safe description. + logger.exception("Cannot connect to server '%s'", server_name) + raise upstream_error("Cannot connect to server", e) # ── Servers ── @@ -179,7 +187,8 @@ async def gateway_pull( result = await client.docker.pull_image(image_name, tag) return {"pulled": True, "image": req.image, "result": result} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to pull gateway image '%s' on '%s'", req.image, server) + raise upstream_error("Failed to pull gateway image", e) @router.get("/gateway/pull-status") @@ -195,7 +204,8 @@ async def gateway_pull_status( result = await client.docker.get_pull_status() return result except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to fetch gateway pull status from '%s'", server) + raise upstream_error("Failed to fetch pull status", e) @router.post("/gateway/start") @@ -217,7 +227,8 @@ async def gateway_start( ) return {"started": True, "result": result} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to start gateway on '%s'", server) + raise upstream_error("Failed to start gateway", e) @router.post("/gateway/stop") @@ -233,7 +244,8 @@ async def gateway_stop( result = await client.gateway.stop() return {"stopped": True, "result": result} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to stop gateway on '%s'", server) + raise upstream_error("Failed to stop gateway", e) @router.post("/gateway/restart") @@ -249,7 +261,8 @@ async def gateway_restart( result = await client.gateway.restart() return {"restarted": True, "result": result} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to restart gateway on '%s'", server) + raise upstream_error("Failed to restart gateway", e) @router.get("/gateway/logs") @@ -265,7 +278,8 @@ async def gateway_logs( logs = await client.gateway.get_logs() return {"logs": logs} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to fetch gateway logs from '%s'", server) + raise upstream_error("Failed to fetch gateway logs", e) # ── Voice Preferences ── @@ -361,7 +375,8 @@ async def list_credentials( ) return {"credentials": credentials} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("Failed to list credentials on '%s'", server) + raise upstream_error("Failed to list credentials", e) @router.get("/connectors") @@ -418,7 +433,10 @@ async def connector_config_map( config_map = await client.connectors.get_config_map(name) return {"config_map": config_map} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception( + "Failed to fetch config map for connector '%s' on '%s'", name, server + ) + raise upstream_error("Failed to fetch connector config map", e) @router.post("/credentials") @@ -443,7 +461,10 @@ async def add_credential( get_server_data_service().invalidate(server, ServerDataType.CONNECTORS) return {"added": True, "result": result} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception( + "Failed to add credentials for '%s' on '%s'", req.connector_name, server + ) + raise upstream_error("Failed to add credentials", e) @router.delete("/credentials/{connector}") @@ -469,7 +490,10 @@ async def delete_credential( sds.invalidate(server, ServerDataType.PORTFOLIO) return {"deleted": True, "result": result} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception( + "Failed to delete credentials for '%s' on '%s'", connector, server + ) + raise upstream_error("Failed to delete credentials", e) # ── Custom OpenAI-compatible LLM endpoints ── diff --git a/tests/test_route_upstream_errors.py b/tests/test_route_upstream_errors.py new file mode 100644 index 00000000..b879773b --- /dev/null +++ b/tests/test_route_upstream_errors.py @@ -0,0 +1,297 @@ +"""Tests for SEC-130: no route module names the backend in a failed response. + +SEC-116 established the rule for the executor mutations and SEC-126 extended it +to the executor reads: ``str(exc)`` on an ``aiohttp`` client exception embeds the +backend's own URL, so raising it as an ``HTTPException`` detail publishes the +internal host and port to anyone who can provoke a backend blip. Four more route +modules — ``bots``, ``settings``, ``market``, ``controller_performance`` — were +still doing exactly that at every backend call boundary. + +They now go through the same helper, lifted into ``condor/web/routes/_errors.py`` +so there is one implementation rather than five. These tests pin both halves of +the contract: the client loses the address, and the operator does not — the full +exception still reaches the server log, because a redaction that also destroys +the diagnostic is not a fix. +""" + +import asyncio +import logging +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest +from aiohttp import ClientConnectorError, ClientResponseError, RequestInfo +from fastapi import HTTPException +from multidict import CIMultiDict +from yarl import URL + +import condor.web.routes.bots as bots_module +import condor.web.routes.controller_performance as cperf_module +import condor.web.routes.market as market_module +import condor.web.routes.settings as settings_module +from condor.web.models import WebUser + +# The internal address a trader on a shared server must never be shown. +BACKEND_URL = "http://10.0.0.7:8000/bot-orchestration/status" +BACKEND_HOST = "10.0.0.7" +BACKEND_PORT = "8000" + + +def _api_error(status: int, detail: str) -> ClientResponseError: + """The error the hummingbot client raises: API ``detail`` + backend URL.""" + info = RequestInfo( + url=URL(BACKEND_URL), + method="GET", + headers=CIMultiDict(), + real_url=URL(BACKEND_URL), + ) + return ClientResponseError(info, (), status=status, message=detail) + + +def _transport_error() -> ClientConnectorError: + """What a backend outage raises: no HTTP answer, host in the string.""" + return ClientConnectorError( + SimpleNamespace(host=BACKEND_HOST, port=int(BACKEND_PORT), ssl=None), + OSError(61, "Connection refused"), + ) + + +class _RaisingNamespace: + """Any method looked up on this raises the bound exception.""" + + def __init__(self, exc): + self._exc = exc + + def __getattr__(self, _name): + async def _call(*_args, **_kwargs): + raise self._exc + + return _call + + +class FakeClient: + """API client whose every sub-API fails the same way.""" + + def __init__(self, exc): + self._exc = exc + + def __getattr__(self, _name): + return _RaisingNamespace(self._exc) + + +class _FakeCM: + def __init__(self, client): + self._client = client + + def has_server_access(self, *_args, **_kwargs): + return True + + async def get_client(self, _name): + return self._client + + +_USER = WebUser(id=1, role="admin") + + +# --- One backend-call endpoint per converted module --- + + +def _bots_status(): + return asyncio.run(bots_module.get_bot(name="srv", bot_id="bot-1", user=_USER)) + + +def _settings_pull_status(): + return asyncio.run(settings_module.gateway_pull_status(server="srv", user=_USER)) + + +def _market_order_book(): + return asyncio.run( + market_module.get_order_book( + name="srv", + connector="binance", + trading_pair="SOL-USDC", + depth=20, + user=_USER, + ) + ) + + +def _cperf_delete_run(): + return asyncio.run( + cperf_module.delete_bot_run(name="srv", bot_run_id=7, user=_USER) + ) + + +ENDPOINTS = [ + pytest.param(bots_module, _bots_status, id="bots-get-bot-status"), + pytest.param(settings_module, _settings_pull_status, id="settings-pull-status"), + pytest.param(market_module, _market_order_book, id="market-order-book"), + pytest.param(cperf_module, _cperf_delete_run, id="cperf-delete-bot-run"), +] + + +@pytest.fixture +def failing_backend(monkeypatch): + """Point a route module's config manager at a client that always fails.""" + + def _bind(module, exc): + monkeypatch.setattr( + module, "get_config_manager", lambda: _FakeCM(FakeClient(exc)) + ) + + return _bind + + +@pytest.mark.parametrize("module,call", ENDPOINTS) +def test_a_backend_outage_never_shows_the_backend_address( + module, call, failing_backend +): + exc = _transport_error() + # Precondition: the raw string really does carry the internal address. + assert BACKEND_HOST in str(exc) + failing_backend(module, exc) + + with pytest.raises(HTTPException) as caught: + call() + + assert caught.value.status_code == 502, "an unreachable backend is not a 400" + assert BACKEND_HOST not in caught.value.detail + assert BACKEND_PORT not in caught.value.detail + assert BACKEND_URL not in caught.value.detail + + +@pytest.mark.parametrize("module,call", ENDPOINTS) +def test_an_api_rejection_never_shows_the_backend_address( + module, call, failing_backend +): + exc = _api_error(400, "unknown bot name") + assert BACKEND_HOST in str(exc) + failing_backend(module, exc) + + with pytest.raises(HTTPException) as caught: + call() + + assert caught.value.status_code == 400, "an upstream 4xx is the caller's own" + assert "unknown bot name" in caught.value.detail, "the API's own reason survives" + assert BACKEND_HOST not in caught.value.detail + assert BACKEND_PORT not in caught.value.detail + + +@pytest.mark.parametrize("module,call", ENDPOINTS) +def test_an_upstream_5xx_is_a_502(module, call, failing_backend): + failing_backend(module, _api_error(503, "backend restarting")) + + with pytest.raises(HTTPException) as caught: + call() + + assert caught.value.status_code == 502 + + +@pytest.mark.parametrize("module,call", ENDPOINTS) +def test_the_full_exception_still_reaches_the_server_log( + module, call, failing_backend, caplog +): + """Only the client loses the address — the operator keeps the diagnostic.""" + failing_backend(module, _transport_error()) + + with caplog.at_level(logging.ERROR, logger=module.__name__): + with pytest.raises(HTTPException): + call() + + assert BACKEND_HOST in caplog.text, "diagnostics were lost, not just redacted" + assert any( + record.exc_info for record in caplog.records + ), "the traceback is what makes the log entry actionable" + + +# --- The settings helper every settings endpoint funnels through --- + + +def test_a_client_that_cannot_be_built_does_not_name_the_backend_either(monkeypatch): + """``_get_client`` interpolated the exception straight into the detail.""" + + class _UnreachableCM: + def has_server_access(self, *_a, **_kw): + return True + + async def get_client(self, _name): + raise _transport_error() + + monkeypatch.setattr(settings_module, "get_config_manager", _UnreachableCM) + + with pytest.raises(HTTPException) as caught: + asyncio.run(settings_module.gateway_pull_status(server="srv", user=_USER)) + + assert caught.value.status_code == 502 + assert BACKEND_HOST not in caught.value.detail + assert BACKEND_PORT not in caught.value.detail + + +def test_an_unknown_server_still_says_so(monkeypatch): + """The config manager's own ValueError names only what the caller typed.""" + + class _NoSuchServerCM: + def has_server_access(self, *_a, **_kw): + return True + + async def get_client(self, name): + raise ValueError(f"Server '{name}' not found") + + monkeypatch.setattr(settings_module, "get_config_manager", _NoSuchServerCM) + + with pytest.raises(HTTPException) as caught: + asyncio.run(settings_module.gateway_pull_status(server="srv", user=_USER)) + + assert "not found" in caught.value.detail + + +# --- The pattern must not creep back in --- + +_LEAK = re.compile(r"detail=str\((e|exc)\)") +_BARE_EXCEPT = re.compile(r"^\s*except (Exception|BaseException) as (e|exc):") + +CONVERTED_MODULES = [bots_module, settings_module, market_module, cperf_module] + + +@pytest.mark.parametrize( + "module", CONVERTED_MODULES, ids=lambda m: m.__name__.rsplit(".", 1)[-1] +) +def test_no_backend_call_stringifies_the_exception_into_the_detail(module): + """A bare ``except Exception`` is what catches the aiohttp client errors. + + The handful of ``detail=str(e)`` sites left in these modules catch a *named* + domain exception — a rejected identifier, an unparseable URL — whose message + is our own text about the caller's own input. Those are the correct message + for the caller and carry no address. What must never come back is the bare + catch-all feeding the exception string to the client. + """ + lines = Path(module.__file__).read_text().split("\n") + + offenders = [] + for index, line in enumerate(lines): + if not _LEAK.search(line): + continue + clause = next( + ( + lines[back] + for back in range(index, -1, -1) + if lines[back].lstrip().startswith("except ") + ), + "", + ) + if _BARE_EXCEPT.match(clause): + offenders.append(f"{module.__file__}:{index + 1}: {line.strip()}") + + assert not offenders, "a backend call leaks the exception string:\n" + "\n".join( + offenders + ) + + +def test_the_shared_helper_is_the_only_mapping(): + """Each converted module raises through the shared helper, not its own copy.""" + for module in CONVERTED_MODULES: + source = Path(module.__file__).read_text() + assert "from condor.web.routes._errors import upstream_error" in source + assert "raise upstream_error(" in source + assert "describe_executor_error" not in source, "no second sanitizer" From 5096c16f9f3f74738cb1e34abce2f42bc6adadbe Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:33:28 +0300 Subject: [PATCH 013/116] Let an install opt in to telling us how it is used (FEAT-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Condor is self-hosted, so the project learns nothing about adoption, feature usage, reliability or agent economics unless an install chooses to say. This is the machinery for that choice, and nothing else yet: no call site emits. The whole thing is built around the fact that this process holds exchange API keys. Consent is opt-in and its default is silence: an install with no answer recorded resolves to level `off`, at which emit() returns before it has looked at its arguments — no buffer, no spool file, no directory. schema.py declares every event and every property, and sanitize() drops anything undeclared, so "we never collect balances, keys, pairs or prompts" is a property of the code rather than a promise the call sites are trusted to keep. No collector address is compiled in. With CONDOR_TELEMETRY_URL unset — the shipped state — the send path is inert and events can only reach a capped local outbox, which is the correct behaviour until the ingest service exists. emit() never raises and never does I/O in the host process; it appends to a bounded ring, and a token bucket keeps a crash loop from turning `error` into a flood. The MCP server runs in its own process with no job_queue, so it spools to its own pid-scoped file and the host drains it. --- condor/telemetry/__init__.py | 82 +++++++ condor/telemetry/consent.py | 255 ++++++++++++++++++++ condor/telemetry/context.py | 147 ++++++++++++ condor/telemetry/emitter.py | 178 ++++++++++++++ condor/telemetry/outbox.py | 224 +++++++++++++++++ condor/telemetry/prompt.py | 127 ++++++++++ condor/telemetry/schema.py | 324 +++++++++++++++++++++++++ condor/telemetry/taps.py | 453 +++++++++++++++++++++++++++++++++++ config_manager.py | 24 ++ utils/config.py | 15 ++ 10 files changed, 1829 insertions(+) create mode 100644 condor/telemetry/__init__.py create mode 100644 condor/telemetry/consent.py create mode 100644 condor/telemetry/context.py create mode 100644 condor/telemetry/emitter.py create mode 100644 condor/telemetry/outbox.py create mode 100644 condor/telemetry/prompt.py create mode 100644 condor/telemetry/schema.py create mode 100644 condor/telemetry/taps.py diff --git a/condor/telemetry/__init__.py b/condor/telemetry/__init__.py new file mode 100644 index 00000000..57760619 --- /dev/null +++ b/condor/telemetry/__init__.py @@ -0,0 +1,82 @@ +"""Anonymous, opt-in usage telemetry (FEAT-023). + +Condor is self-hosted, so the project sees nothing about how installs are used +unless an install chooses to tell it. This package is that channel — and, +because the same process holds exchange API keys, it is built to be auditable in +one sitting rather than to be clever. + +The four facts that define it: + +- **Off by default.** A fresh clone has no consent recorded, which resolves to + level ``off``, at which :func:`emit` returns before doing anything at all. No + buffer, no file, no directory. +- **Opt-in, once, by the admin.** One inline-keyboard prompt on boot offers + full usage, install-count-only, or no. The answer is durable and reversible. +- **Allowlisted.** :mod:`condor.telemetry.schema` declares every event and every + property. Anything undeclared is dropped by construction, which is what makes + the "never collected" list in ``PRIVACY.md`` a property of the code. +- **Nothing transmits without an endpoint.** No collector URL is compiled in. + Unset ``CONDOR_TELEMETRY_URL`` means the send path is inert and events only + ever reach a capped local file. + +Public surface — call sites should need nothing else:: + + from condor import telemetry + telemetry.emit("command", name="portfolio", surface="telegram") +""" + +from condor.telemetry.consent import ( + DENIED, + GRANTED, + OFF, + PING, + UNKNOWN, + USAGE, + is_on, + level, +) +from condor.telemetry.consent import state as consent_state +from condor.telemetry.emitter import emit, flush + +__all__ = [ + "emit", + "flush", + "level", + "is_on", + "consent_state", + "init", + "shutdown", + "OFF", + "PING", + "USAGE", + "UNKNOWN", + "GRANTED", + "DENIED", +] + + +def init(hosted: bool = True) -> str: + """Prime this process. Returns the effective level. + + ``hosted`` marks a process that owns a flush job, so events buffer in memory + instead of going straight to a spool file. The MCP subprocess passes False. + + Priming does two things and only two: it resolves the level once so + :func:`emit` never has to read the disk on a hot path, and — only if the + level is not ``off`` — it materializes the install's random ids. An install + that never opted in is left exactly as it was found. + """ + from condor.telemetry import consent, emitter + + emitter.set_hosted(hosted) + effective = consent.refresh() + if effective != consent.OFF: + consent.ensure_identity() + return effective + + +def shutdown(reason: str = "signal") -> None: + """Record that the process is going down. Sending is still the job's problem.""" + from condor.telemetry import context + + emit("shutdown", reason=reason, uptime_h=context.uptime_h()) diff --git a/condor/telemetry/consent.py b/condor/telemetry/consent.py new file mode 100644 index 00000000..d59c8416 --- /dev/null +++ b/condor/telemetry/consent.py @@ -0,0 +1,255 @@ +"""Consent: the three-state machine that decides whether anything happens at all. + +The install — not the individual user — is the unit of consent, because the +admin owns the install. The states are ``unknown`` (the default on a fresh +clone), ``granted`` and ``denied``, stored in ``config.yml`` under ``telemetry`` +alongside the install's identity. + +Two rules matter more than the rest: + +**Unknown means off.** Not "buffered until someone decides" — off. An install +whose admin has never answered the prompt collects nothing, holds nothing in +memory, and writes no spool file. The original design buffered pre-consent +events and simply refused to send them; recording data before permission is a +worse default than losing it, so this narrows that decision. Nothing is lost +that we had any right to. + +**The environment wins.** ``CONDOR_TELEMETRY`` in ``utils/config.py`` overrides +the stored answer in both directions, so a headless or containerized install is +never blocked on a Telegram prompt, and an operator can force ``off`` no matter +what is on disk. + +Reads never create ``config.yml``. On an install that has not been configured at +all, :func:`state` answers ``unknown`` from nothing. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from pathlib import Path + +log = logging.getLogger(__name__) + +OFF = "off" +PING = "ping" +USAGE = "usage" +LEVELS = (OFF, PING, USAGE) + +UNKNOWN = "unknown" +GRANTED = "granted" +DENIED = "denied" + +# Answer -> level, the three buttons of the admin prompt. +ANSWER_LEVELS = {"usage": USAGE, "ping": PING, "off": OFF} + +_cached_level: str | None = None + + +def _config_path() -> Path: + from config_manager import ConfigManager + + existing = ConfigManager._instance + return Path(existing.config_path if existing else "config.yml") + + +def _cm(): + """The ConfigManager, but only if reading it cannot create a config file.""" + from config_manager import ConfigManager + + if ConfigManager._instance is None and not Path("config.yml").exists(): + return None + try: + return ConfigManager.instance() + except Exception: # pragma: no cover - a broken config must not break a tap + log.debug("Telemetry could not read config", exc_info=True) + return None + + +def _section() -> dict: + cm = _cm() + if cm is None: + return {} + try: + return cm.get_telemetry() + except Exception: # pragma: no cover + return {} + + +def _update(**changes) -> None: + cm = _cm() + if cm is None: + from config_manager import get_config_manager + + cm = get_config_manager() + cm.update_telemetry(**changes) + refresh() + + +def _env_level() -> str | None: + """The operator's override, or None when unset/nonsense.""" + from utils.config import CONDOR_TELEMETRY + + if CONDOR_TELEMETRY in LEVELS: + return CONDOR_TELEMETRY + if CONDOR_TELEMETRY: + log.warning( + "Ignoring CONDOR_TELEMETRY=%r: expected one of %s", + CONDOR_TELEMETRY, + ", ".join(LEVELS), + ) + return None + + +def state() -> str: + """``unknown`` | ``granted`` | ``denied``.""" + value = _section().get("consent") + return value if value in (UNKNOWN, GRANTED, DENIED) else UNKNOWN + + +def level() -> str: + """The effective level. Cached, because :func:`emit` reads it every time.""" + global _cached_level + if _cached_level is None: + _cached_level = _compute_level() + return _cached_level + + +def _compute_level() -> str: + env = _env_level() + if env is not None: + return env + if state() != GRANTED: + return OFF + stored = _section().get("level") + return stored if stored in LEVELS else USAGE + + +def refresh() -> str: + """Drop the cached level and recompute it. Called after any state change.""" + global _cached_level + _cached_level = None + return level() + + +def is_on() -> bool: + return level() != OFF + + +def env_overridden() -> bool: + """True when the operator has pinned the level, so no prompt should be sent.""" + return _env_level() is not None + + +# ── Identity ───────────────────────────────────────────────────────────── +# Both ids are random. Neither is derived from a MAC address, a hostname, a +# username or a token, so neither can be reversed into anything about the host. + + +def ensure_identity() -> dict: + """Create the install's ids if they do not exist yet. Writes ``config.yml``. + + Called from :func:`condor.telemetry.init` only when telemetry is actually + on, so a fresh install that never opts in never grows the section — and + ``emit()`` never has to touch the disk to find an id. + """ + section = _section() + changes = {} + if not section.get("install_id"): + changes["install_id"] = uuid.uuid4().hex + if not section.get("install_secret"): + changes["install_secret"] = uuid.uuid4().hex + if changes: + _update(**changes) + section = _section() + return section + + +def install_id() -> str: + return _section().get("install_id") or "" + + +def install_secret() -> str: + """Never transmitted, never logged. Only ever salts a local hash.""" + return _section().get("install_secret") or "" + + +# ── Transitions ────────────────────────────────────────────────────────── + + +def grant(answer: str) -> str: + """Record a positive answer. ``answer`` is one of :data:`ANSWER_LEVELS`.""" + chosen = ANSWER_LEVELS.get(answer, USAGE) + if chosen == OFF: + return deny() + _update( + consent=GRANTED, + level=chosen, + decided_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + ensure_identity() + return chosen + + +def deny() -> str: + """Record a refusal and destroy whatever was already on disk. + + Denial deletes the spool and the outbox rather than merely ignoring them: + an answer of "no" should leave nothing behind to be sent by a later bug. + """ + _update( + consent=DENIED, + level=OFF, + decided_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + from condor.telemetry import emitter, outbox + + emitter.discard_buffer() + outbox.purge() + return OFF + + +def set_level(new_level: str) -> str: + """Change the level of an install that has already consented.""" + if new_level not in LEVELS: + return level() + if new_level == OFF: + return deny() + _update(consent=GRANTED, level=new_level) + ensure_identity() + return new_level + + +# ── First-use tracking ─────────────────────────────────────────────────── + + +def mark_feature_seen(feature: str) -> bool: + """True the first time this install ever uses ``feature``, False after. + + Backs the ``feature_first_use`` activation funnel. Only called when + telemetry is on, so an opted-out install never accumulates the list. + """ + section = _section() + seen = list(section.get("features_seen") or []) + if feature in seen: + return False + seen.append(feature) + _update(features_seen=seen[-200:]) + return True + + +# ── Prompt bookkeeping ─────────────────────────────────────────────────── + + +def should_prompt(version: str = "") -> bool: + """Has this install never been asked (or not since this version)?""" + if env_overridden() or state() != UNKNOWN: + return False + asked = _section().get("prompted_version") + return asked != (version or "unknown") + + +def mark_prompted(version: str = "") -> None: + """Written *before* the prompt is sent, so a crash loop cannot re-ask forever.""" + _update(prompted_version=version or "unknown") diff --git a/condor/telemetry/context.py b/condor/telemetry/context.py new file mode 100644 index 00000000..e5202e82 --- /dev/null +++ b/condor/telemetry/context.py @@ -0,0 +1,147 @@ +"""The install context: who is reporting, not who is using. + +Everything here describes the *deployment* — a random id, a git sha, a platform +triple, and a handful of counts and booleans. It is computed once per process +and sent once per batch rather than once per event, which is both cheaper and a +smaller surface to audit. + +What is deliberately not here: hostname, IP, MAC, username, home directory, +timezone, locale, server names or URLs, and any count that could be traced to a +person. ``user_count`` is a number, never a list. +""" + +from __future__ import annotations + +import logging +import os +import platform +import subprocess +import sys +import time +from pathlib import Path + +log = logging.getLogger(__name__) + +_REPO = Path(__file__).resolve().parent.parent.parent +_started_at = time.monotonic() + +_app: dict | None = None + + +def uptime_h() -> float: + return round((time.monotonic() - _started_at) / 3600.0, 3) + + +def _git(*args: str) -> str: + """A short, bounded git read. Returns '' rather than raising, ever.""" + try: + out = subprocess.run( + ["git", *args], + cwd=str(_REPO), + capture_output=True, + text=True, + timeout=3, + ) + return out.stdout.strip() if out.returncode == 0 else "" + except Exception: + return "" + + +def _in_docker() -> bool: + try: + if Path("/.dockerenv").exists(): + return True + cgroup = Path("/proc/self/cgroup") + return cgroup.exists() and "docker" in cgroup.read_text() + except Exception: + return False + + +def app() -> dict: + """Version and platform. Computed once — the git calls are not free.""" + global _app + if _app is None: + # An install deployed from a tarball rather than a clone reports + # "unknown", which the collector treats as its own bucket. + _app = { + "version": _git("rev-parse", "--short", "HEAD") or "unknown", + "branch": _git("rev-parse", "--abbrev-ref", "HEAD") or "unknown", + "python": f"{sys.version_info.major}.{sys.version_info.minor}", + "os": platform.system().lower(), + "arch": platform.machine().lower(), + "in_docker": _in_docker(), + } + return dict(_app) + + +def version() -> str: + return app()["version"] + + +def _llm_providers() -> list[str]: + """Which provider *slots* are configured. Names only — never a key, never a + prefix of a key, never its length.""" + found = [] + for name, var in ( + ("openai", "OPENAI_API_KEY"), + ("anthropic", "ANTHROPIC_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ("google", "GEMINI_API_KEY"), + ("groq", "GROQ_API_KEY"), + ("deepseek", "DEEPSEEK_API_KEY"), + ): + if os.environ.get(var, "").strip(): + found.append(name) + return found + + +def config_shape() -> dict: + """Counts and capability flags. Every field is a bool, an int, or a fixed name.""" + shape: dict = { + "has_web": True, + "has_gateway": False, + "has_hb_api": False, + "llm_providers": _llm_providers(), + "user_count": 0, + "server_count": 0, + "agent_count": 0, + } + try: + from condor.telemetry.consent import _cm + + cm = _cm() + if cm is not None: + servers = cm.list_servers() or {} + shape["server_count"] = len(servers) + shape["has_hb_api"] = bool(servers) + shape["user_count"] = len(cm.get_all_users() or []) + except Exception: + log.debug("Telemetry could not read config shape", exc_info=True) + try: + agents_dir = _REPO / "agents" + shape["agent_count"] = sum( + 1 for p in agents_dir.iterdir() if (p / "AGENT.md").is_file() + ) + except Exception: + pass + try: + shape["has_gateway"] = bool(os.environ.get("GATEWAY_URL", "").strip()) + except Exception: + pass + return shape + + +def envelope(events: list[dict], dropped: int, level: str) -> dict: + """The wire format. One context, many events, one idempotency key each.""" + from condor.telemetry import consent + + return { + "schema": 1, + "install_id": consent.install_id(), + "level": level, + "sent_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "app": app(), + "config": config_shape(), + "dropped": dropped, + "events": events, + } diff --git a/condor/telemetry/emitter.py b/condor/telemetry/emitter.py new file mode 100644 index 00000000..b740b546 --- /dev/null +++ b/condor/telemetry/emitter.py @@ -0,0 +1,178 @@ +"""The emitter: one function, four gates, and a contract that makes it safe. + +``emit()`` is called from order paths, agent turns and error handlers. Its +contract is what makes that acceptable: + +1. **It never raises.** Every failure inside it is swallowed. Telemetry that can + break the bot is worse than no telemetry, so the whole body is wrapped and + the fallback is silence. +2. **It never blocks and never does I/O** in the host process. It appends to a + bounded ``deque``; the network is somebody else's job, 15 minutes later. +3. **It is off unless someone said yes.** The first gate is a cached level read, + and on an install that has not opted in that read returns ``off`` and the + function returns before it has looked at its own arguments. +4. **It cannot emit what the schema does not declare.** Properties are + allowlisted, type-checked and truncated by :mod:`condor.telemetry.schema`. + +The rate limiter is the fourth gate and exists for one specific failure: an +error loop turning the ``error`` event into a self-inflicted flood. Overflow +increments ``dropped``, which rides in the envelope — the collector sees an +honest count instead of a suspiciously quiet incident. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from collections import deque + +log = logging.getLogger(__name__) + +RING_MAX = 2000 +RATE_PER_MIN = 60 +FLUSH_THRESHOLD = 200 + +_buffer: deque[dict] = deque(maxlen=RING_MAX) +_dropped = 0 +_tokens: dict[str, tuple[float, float]] = {} + +# False until a process registers a flush job (see condor.telemetry.init). A +# process without one — the out-of-process MCP server — writes straight to its +# own spool file instead, because an in-memory ring there would be lost on exit. +_hosted = False + + +def set_hosted(value: bool) -> None: + global _hosted + _hosted = value + + +def is_hosted() -> bool: + return _hosted + + +def _take_token(name: str) -> bool: + """Token bucket, :data:`RATE_PER_MIN` per event name per minute.""" + now = time.monotonic() + tokens, last = _tokens.get(name, (float(RATE_PER_MIN), now)) + tokens = min(float(RATE_PER_MIN), tokens + (now - last) * RATE_PER_MIN / 60.0) + if tokens < 1.0: + _tokens[name] = (tokens, now) + return False + _tokens[name] = (tokens - 1.0, now) + return True + + +def emit(name: str, /, **props) -> None: + """Record one event. Synchronous, never raises, never does I/O. + + The event name is positional-only so that an event may legitimately declare + a property called ``name`` (``command`` does) without colliding with it. + + Unknown event names and undeclared properties are dropped rather than + reported, so a stale call site degrades to silence instead of an exception + on a trading path. + """ + global _dropped + try: + from condor.telemetry import consent, schema + + level = consent.level() + if level == consent.OFF: + return + if not schema.allowed_at(name, level): + return + if not _take_token(name): + _dropped += 1 + return + clean = schema.sanitize(name, props) + if clean is None: + return + event = { + "id": uuid.uuid4().hex, + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "name": name, + "props": clean, + } + if _hosted: + if len(_buffer) == RING_MAX: + _dropped += 1 # the deque is about to evict the oldest + _buffer.append(event) + else: + from condor.telemetry import outbox + + outbox.spool(event) + except Exception: # noqa: BLE001 - the whole point of this function + try: + log.debug("Telemetry emit failed for %r", name, exc_info=True) + except Exception: + pass + + +def buffered() -> int: + return len(_buffer) + + +def dropped() -> int: + return _dropped + + +def should_flush() -> bool: + return len(_buffer) >= FLUSH_THRESHOLD + + +def drain() -> tuple[list[dict], int]: + """Take the ring and the dropped count together, and reset both.""" + global _dropped + events = list(_buffer) + _buffer.clear() + count, _dropped = _dropped, 0 + return events, count + + +def discard_buffer() -> None: + """Throw the ring away without sending it. Used when consent is denied.""" + global _dropped + _buffer.clear() + _dropped = 0 + _tokens.clear() + + +async def flush(reason: str = "job") -> int: + """Try to deliver everything pending. Returns the number of events sent. + + Returns 0 without reading a file when consent is not granted, and 0 without + touching the network when no endpoint is configured — the shipped state, in + which events simply accumulate in the capped outbox. + """ + try: + from condor.telemetry import consent, context, outbox + + if consent.state() != consent.GRANTED or consent.level() == consent.OFF: + return 0 + + events, dropped_count = drain() + events.extend(outbox.drain_spools()) + events.extend(outbox.take_stashed()) + if not events: + return 0 + + if not outbox.endpoint(): + # Nowhere to go yet (the collector is FEAT-024). Park them; the cap + # in outbox.py is what keeps this honest on a long-running install. + outbox.stash(events) + return 0 + + sent = 0 + for batch in outbox.batches(events): + envelope = context.envelope(batch, dropped_count, consent.level()) + dropped_count = 0 + if await outbox.post(envelope): + sent += len(batch) + else: + outbox.stash(batch) + return sent + except Exception: + log.debug("Telemetry flush failed (%s)", reason, exc_info=True) + return 0 diff --git a/condor/telemetry/outbox.py b/condor/telemetry/outbox.py new file mode 100644 index 00000000..8f4b5f7e --- /dev/null +++ b/condor/telemetry/outbox.py @@ -0,0 +1,224 @@ +"""Durable spool and the (deliberately inert) send path. + +Two files live under ``condor/.runtime/telemetry/``, the same gitignored place +the rest of the runtime keeps its append-only facts: + +``spool..jsonl`` + Written by processes that have no flush job of their own — in practice the + MCP server, which the agent spawns in its own process group with its own + interpreter and no ``job_queue``. Each process owns its own file, so there + is no locking and no interleaving; the host drains and deletes them. + +``outbox.jsonl`` + Where a batch goes when it could not be delivered. Capped at + :data:`MAX_OUTBOX_EVENTS` events and :data:`MAX_OUTBOX_AGE_S`, oldest first, + so an install that never reaches a collector accumulates a bounded file and + then quietly drops the excess. That is the intended behaviour, not a bug. + +**Nothing is transmitted unless an operator sets ``CONDOR_TELEMETRY_URL``.** No +collector address is compiled into this repository. With the variable unset — +which is the shipped state — :func:`post` returns ``False`` without importing a +network client, and every event's whole life is a local file that +:func:`purge` can delete. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +from pathlib import Path + +log = logging.getLogger(__name__) + +MAX_OUTBOX_EVENTS = 5000 +MAX_OUTBOX_AGE_S = 7 * 24 * 3600 +MAX_BATCH_BYTES = 512 * 1024 +POST_TIMEOUT_S = 10 + + +def root() -> Path: + """Where the spool lives. Derived like every other runtime store.""" + from condor.agents.agent import _DATA_ROOT + + return Path(_DATA_ROOT).parent / "condor" / ".runtime" / "telemetry" + + +def spool_path(pid: int | None = None) -> Path: + return root() / f"spool.{pid or os.getpid()}.jsonl" + + +def outbox_path() -> Path: + return root() / "outbox.jsonl" + + +def _append(path: Path, records: list[dict]) -> None: + if not records: + return + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as fh: + for record in records: + fh.write(json.dumps(record, separators=(",", ":")) + "\n") + + +def _read(path: Path) -> list[dict]: + if not path.exists(): + return [] + events = [] + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except ValueError: + continue # a torn last line from a killed process + except OSError: + log.debug("Telemetry could not read %s", path, exc_info=True) + return events + + +def spool(event: dict) -> None: + """Append one event to this process's own spool file.""" + _append(spool_path(), [event]) + + +def drain_spools() -> list[dict]: + """Take everything other processes left behind, including our own file. + + A spool file still being written by a live process is left alone unless it + is ours: deleting it would lose whatever landed between read and unlink. + """ + directory = root() + if not directory.is_dir(): + return [] + mine = os.getpid() + events: list[dict] = [] + for path in sorted(directory.glob("spool.*.jsonl")): + try: + pid = int(path.name.split(".")[1]) + except (IndexError, ValueError): + continue + if pid != mine and _alive(pid): + continue + events.extend(_read(path)) + try: + path.unlink() + except OSError: + pass + return events + + +def _alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, ValueError): + return False + except PermissionError: + return True + + +def stash(events: list[dict]) -> None: + """Park undeliverable events, then enforce the cap.""" + if not events: + return + _append(outbox_path(), events) + _trim() + + +def take_stashed() -> list[dict]: + """Read and clear the outbox, so a flush can retry it alongside fresh events.""" + path = outbox_path() + events = _read(path) + try: + path.unlink() + except OSError: + pass + return events + + +def _trim() -> None: + path = outbox_path() + events = _read(path) + cutoff = time.time() - MAX_OUTBOX_AGE_S + kept = [e for e in events if _epoch(e) >= cutoff][-MAX_OUTBOX_EVENTS:] + if len(kept) == len(events): + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + for record in kept: + fh.write(json.dumps(record, separators=(",", ":")) + "\n") + tmp.replace(path) + except OSError: + log.debug("Telemetry could not trim the outbox", exc_info=True) + + +def _epoch(event: dict) -> float: + try: + return time.mktime(time.strptime(event["ts"], "%Y-%m-%dT%H:%M:%SZ")) + except Exception: + return time.time() + + +def purge() -> None: + """Delete every trace. Called when consent is denied or withdrawn.""" + directory = root() + if not directory.is_dir(): + return + for path in list(directory.glob("*.jsonl")) + list(directory.glob("*.tmp")): + try: + path.unlink() + except OSError: + pass + try: + directory.rmdir() + except OSError: + pass + + +def batches(events: list[dict]) -> list[list[dict]]: + """Split into envelopes no bigger than :data:`MAX_BATCH_BYTES`.""" + out: list[list[dict]] = [] + current: list[dict] = [] + size = 0 + for event in events: + length = len(json.dumps(event, separators=(",", ":"))) + if current and size + length > MAX_BATCH_BYTES: + out.append(current) + current, size = [], 0 + current.append(event) + size += length + if current: + out.append(current) + return out + + +def endpoint() -> str | None: + """The collector URL, or None — which is the shipped default.""" + from utils.config import CONDOR_TELEMETRY_URL + + return CONDOR_TELEMETRY_URL + + +async def post(envelope: dict) -> bool: + """Deliver one envelope. Returns False — without touching the network — when + no endpoint is configured, which is the state this repository ships in.""" + url = endpoint() + if not url: + return False + try: + import aiohttp + + timeout = aiohttp.ClientTimeout(total=POST_TIMEOUT_S) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=envelope) as response: + return 200 <= response.status < 300 + except Exception: + log.debug("Telemetry POST failed; events stay in the outbox", exc_info=True) + return False diff --git a/condor/telemetry/prompt.py b/condor/telemetry/prompt.py new file mode 100644 index 00000000..9c184637 --- /dev/null +++ b/condor/telemetry/prompt.py @@ -0,0 +1,127 @@ +"""The one-tap consent prompt, and the callback that answers it. + +Opt-in only works if asking is cheap, so this is a single message with three +buttons next to the "Condor is online" notification the admin already gets. It +is sent at most once per version, the intent is written to disk *before* the +message goes out (a crash loop must not re-ask forever), and until it is +answered the install emits nothing at all. +""" + +from __future__ import annotations + +import logging + +log = logging.getLogger(__name__) + +CALLBACK_PREFIX = "telemetry" + +_TEXT = ( + "Help improve Condor?\n\n" + "Condor can send an anonymous, allowlisted usage summary to the project: " + "which commands and screens get used, what breaks, and which models agents " + "run. It is off right now and stays off unless you say yes.\n\n" + "Never included: API keys, wallet addresses, server names or URLs, " + "trading pairs, amounts, balances, positions, prompts or agent replies, " + "and no Telegram id or username.\n\n" + "Full details in PRIVACY.md at the root of the repo. " + "You can change this any time from the dashboard settings." +) + + +def keyboard(): + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "Yes, help improve Condor", callback_data=f"{CALLBACK_PREFIX}:usage" + ) + ], + [ + InlineKeyboardButton( + "Only count my install", callback_data=f"{CALLBACK_PREFIX}:ping" + ) + ], + [InlineKeyboardButton("No thanks", callback_data=f"{CALLBACK_PREFIX}:off")], + ] + ) + + +async def maybe_prompt_admin(bot) -> bool: + """Ask the admin once, if there is anything to ask. Never raises.""" + try: + from utils.config import ADMIN_USER_ID + + if not ADMIN_USER_ID: + return False + + from condor.telemetry import consent, context + + version = context.version() + if not consent.should_prompt(version): + return False + + # Written first: if sending or the process dies right after, the admin + # gets asked again on the next version, not on the next boot loop. + consent.mark_prompted(version) + await bot.send_message( + chat_id=int(ADMIN_USER_ID), text=_TEXT, reply_markup=keyboard() + ) + return True + except Exception: # noqa: BLE001 + log.debug("Could not send the telemetry consent prompt", exc_info=True) + return False + + +async def callback_handler(update, context) -> None: + """Handle ``telemetry:usage|ping|off``. Admin only — it is an install-wide + setting, and the admin owns the install.""" + query = update.callback_query + try: + await query.answer() + except Exception: # noqa: BLE001 + pass + + try: + from condor.telemetry import consent + from condor.telemetry.consent import _cm + + user = getattr(update, "effective_user", None) + cm = _cm() + if cm is None or user is None or not cm.is_admin(int(user.id)): + await query.edit_message_text( + "Only the admin can change the telemetry setting." + ) + return + + answer = ( + (query.data or "").split(":", 1)[1] if ":" in (query.data or "") else "" + ) + if answer == "off": + consent.deny() + await query.edit_message_text( + "Telemetry stays off. Nothing was collected, and the local " + "buffer has been deleted." + ) + return + + chosen = consent.grant(answer) + from condor.telemetry import emitter + + emitter.emit("install") + if chosen == consent.PING: + await query.edit_message_text( + "Thanks. Condor will only report that this install exists and " + "which version it runs. Change it any time in the dashboard " + "settings; details in PRIVACY.md." + ) + else: + await query.edit_message_text( + "Thanks. Condor will send anonymous usage and reliability " + "events. No keys, addresses, pairs, amounts or prompts ever " + "leave this machine. Change it any time in the dashboard " + "settings; details in PRIVACY.md." + ) + except Exception: # noqa: BLE001 + log.exception("Telemetry consent callback failed") diff --git a/condor/telemetry/schema.py b/condor/telemetry/schema.py new file mode 100644 index 00000000..3f8ce848 --- /dev/null +++ b/condor/telemetry/schema.py @@ -0,0 +1,324 @@ +"""THE TAXONOMY — the allowlist that makes the privacy promise a property of code. + +Every event Condor can emit is declared here, with every property it is allowed +to carry and the shape that property must have. :func:`sanitize` is the only way +an event reaches a buffer, and it **drops** anything not declared rather than +passing it through. That is deliberate: the "never collected" list in +``PRIVACY.md`` is not a convention the call sites are trusted to honour, it is +what this module structurally cannot let past. + +Consequences worth stating out loud, because they are the point: + +- An undeclared property is discarded, whatever it is called. A tap that grows a + ``wallet=`` argument by accident leaks nothing. +- Free-form strings are capped at :data:`MAX_STR` characters, so nothing long + enough to be a key, an address, a prompt or a URL survives intact. +- Enum properties are snapped to their allowlist or to ``other``, so cardinality + is bounded by this file rather than by whatever the caller happened to hold. + +Deliberately absent, and asserted by ``tests/test_telemetry.py``: amounts, +balances, PnL, trading pairs, order ids, wallet addresses, server names, URLs, +hostnames, IPs, API keys, Telegram ids or usernames, prompts, agent replies, +routine configs, journal text, and exception message strings. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +# One place to change the blast radius of any single value. +MAX_STR = 64 +MAX_MAP_KEYS = 20 +MAX_LIST = 5 + +# Free-form strings keep only characters our own identifiers use. It is a second +# fence behind the allowlist: even a declared string property cannot carry a +# sentence, a path with a home directory in it, or a URL with a token in it. +_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._:/@#-]+") + + +@dataclass(frozen=True) +class PropSpec: + """What one property of one event is allowed to be.""" + + kind: str # str | int | float | bool | enum | map | list + allowed: tuple[str, ...] = field(default=()) + + def __post_init__(self) -> None: + if self.kind == "enum" and not self.allowed: + raise ValueError("enum PropSpec needs an allowlist") + + +def _s() -> PropSpec: + return PropSpec("str") + + +def _i() -> PropSpec: + return PropSpec("int") + + +def _f() -> PropSpec: + return PropSpec("float") + + +def _b() -> PropSpec: + return PropSpec("bool") + + +def _e(*allowed: str) -> PropSpec: + return PropSpec("enum", tuple(allowed)) + + +SURFACES = ("telegram", "web", "mcp", "strategy", "other") + +# The commands we ship. Anything else a user types is reported as `other`, so a +# typo, a third-party bot's command, or a private fork's addition can never +# widen the value space. +COMMANDS = ( + "start", + "portfolio", + "bots", + "new_bot", + "trade", + "swap", + "lp", + "routines", + "executors", + "agent", + "stop", + "delegations", + "memory", + "cancel", + "servers", + "keys", + "gateway", + "admin", + "update", + "web", +) + +MODULES = ( + "bots", + "cex", + "dex", + "trade", + "agents", + "routines", + "config", + "portfolio", + "executors", + "admin", + "start", + "other", +) + +# Events an install at level `ping` may send. Everything else needs `usage`. +PING_EVENTS = frozenset({"install", "heartbeat", "version_change", "shutdown"}) + + +EVENTS: dict[str, dict[str, PropSpec]] = { + # ── Adoption & retention ───────────────────────────────────────────── + # `install` carries no properties: the envelope context is the payload. + "install": {}, + "heartbeat": { + "uptime_h": _f(), + "active_users_24h": _i(), + "surfaces": PropSpec("map"), + # `health` rides here rather than being its own event. + "hb_api_online": _b(), + "gateway_online": _b(), + "hb_api_version": _s(), + }, + "version_change": { + # Named `from_version`/`to_version`, not `from`/`to`: `from` is a Python + # keyword and emit() takes keyword arguments. + "from_version": _s(), + "to_version": _s(), + "was_behind": _i(), + }, + "shutdown": { + "reason": _e("signal", "crash", "restart"), + "uptime_h": _f(), + }, + # ── Feature usage ──────────────────────────────────────────────────── + "command": { + "name": _e(*COMMANDS, "other"), + "surface": _e(*SURFACES), + "user_hash": _s(), + "authorized": _b(), + }, + "action": { + "module": _e(*MODULES), + "verb": _s(), + "surface": _e(*SURFACES), + "status": _i(), + }, + "feature_first_use": {"feature": _s()}, + "bot_deploy": { + "controller_type": _s(), + "connector": _s(), + "is_paper": _b(), + }, + "executor_deploy": {"executor_type": _s(), "connector": _s()}, + "trade": { + # No trading pair, and no amount. See PRIVACY.md: an install that trades + # one pair, plus timestamps, is a deanonymizable position disclosure. + "venue": _e("cex", "dex"), + "connector": _s(), + "side": _e("buy", "sell", "other"), + "order_type": _s(), + }, + "routine_run": { + "routine": _s(), + "kind": _e("oneshot", "continuous", "other"), + "trigger": _e("manual", "schedule", "background", "web", "mcp", "other"), + "duration_ms": _i(), + "ok": _b(), + }, + # ── Reliability ────────────────────────────────────────────────────── + "error": { + "where": _s(), + "exc_type": _s(), + # A hash of the message, never the message. Grouping still works; the + # balance, hostname or key that message might have contained does not + # survive the hash. + "sig": _s(), + "frames": PropSpec("list"), + "surface": _e(*SURFACES), + "fatal": _b(), + }, + "upstream_error": { + "service": _e("hb_api", "gateway", "llm", "telegram", "other"), + "op": _s(), + "status": _s(), + }, + # ── Agent economics ────────────────────────────────────────────────── + "agent_turn": { + "kind": _e("chat", "consult", "delegate", "tick", "other"), + "provider": _s(), + "model": _s(), + "tool_calls": _i(), + "duration_ms": _i(), + "outcome": _e("done", "error", "aborted"), + "tokens_in": _i(), + "tokens_out": _i(), + "surface": _e(*SURFACES), + # `agent_tools` rides here: a bounded map of tool name -> count. + "tools": PropSpec("map"), + }, + # An MCP tool call observed from the out-of-process server, which cannot see + # the turn it belongs to (see condor/telemetry/outbox.py). Not in the + # original design's table; added so the subprocess spool has something + # declared to carry. + "mcp_tool": {"tool": _s(), "ok": _b(), "duration_ms": _i()}, + "strategy_run": { + "mode": _e("dry_run", "run_once", "loop", "other"), + "frequency_sec": _i(), + "ticks": _i(), + "bot_mode": _s(), + "has_risk_limits": _b(), + "stopped_by": _s(), + }, + "confirmation": { + "tool": _s(), + "decision": _e("allow", "deny", "timeout"), + }, +} + + +def is_known(name: str) -> bool: + return name in EVENTS + + +def allowed_at(name: str, level: str) -> bool: + """Is this event permitted at this telemetry level?""" + if level == "usage": + return name in EVENTS + if level == "ping": + return name in PING_EVENTS + return False + + +def clean_str(value: object) -> str | None: + """Reduce any value to a short, identifier-shaped string, or reject it.""" + if isinstance(value, bool) or not isinstance(value, (str, int, float)): + return None + text = _SAFE_CHARS.sub("_", str(value)).strip("_") + if not text: + return None + return text[:MAX_STR] + + +def _coerce(spec: PropSpec, value: object) -> object | None: + """Force one value into its declared shape, or return None to drop it.""" + if value is None: + return None + + if spec.kind == "bool": + return value if isinstance(value, bool) else None + + if spec.kind == "int": + # bool is an int in Python; a bool in an int slot is a caller bug. + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return int(value) + + if spec.kind == "float": + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return round(float(value), 3) + + if spec.kind == "str": + return clean_str(value) + + if spec.kind == "enum": + text = clean_str(value) + if text is None: + return None + return text if text in spec.allowed else "other" + + if spec.kind == "list": + if not isinstance(value, (list, tuple)): + return None + out = [s for s in (clean_str(v) for v in value[:MAX_LIST]) if s] + return out or None + + if spec.kind == "map": + if not isinstance(value, dict): + return None + out: dict[str, int] = {} + for key, count in list(value.items())[:MAX_MAP_KEYS]: + safe_key = clean_str(key) + if safe_key is None: + continue + if isinstance(count, bool) or not isinstance(count, (int, float)): + continue + out[safe_key] = int(count) + return out or None + + return None + + +def sanitize(name: str, props: dict | None) -> dict | None: + """Return the emittable form of one event, or None if it is not emittable. + + Undeclared properties are dropped silently — the caller is not trusted, and + a warning that names the offending value would only move the leak into the + log file. + """ + spec = EVENTS.get(name) + if spec is None: + return None + if not props: + return {} + + clean: dict = {} + for key, value in props.items(): + prop_spec = spec.get(key) + if prop_spec is None: + continue + coerced = _coerce(prop_spec, value) + if coerced is not None: + clean[key] = coerced + return clean diff --git a/condor/telemetry/taps.py b/condor/telemetry/taps.py new file mode 100644 index 00000000..181d29b7 --- /dev/null +++ b/condor/telemetry/taps.py @@ -0,0 +1,453 @@ +"""The taps: the only code that turns something happening into an event. + +Call sites stay one line long and stay dumb. Everything that decides *what* is +allowed to be said lives here and in :mod:`condor.telemetry.schema`, so the +privacy review has two files to read rather than fifteen. + +Every public function here is defensive to the point of paranoia: a tap runs +inside a command handler, an error handler, an order path and a tick loop, and +a tap that raises would turn "we learned nothing" into "the bot broke". They all +swallow, and :func:`condor.telemetry.emitter.emit` swallows again underneath. +""" + +from __future__ import annotations + +import functools +import hashlib +import logging +import re +import time +import traceback +from pathlib import Path + +from condor.telemetry.emitter import emit + +log = logging.getLogger(__name__) + +TELEMETRY_FLUSH_JOB = "telemetry_flush" +TELEMETRY_HEARTBEAT_JOB = "telemetry_heartbeat" +FLUSH_INTERVAL_S = 15 * 60 +HEARTBEAT_INTERVAL_S = 6 * 3600 + +_REPO = Path(__file__).resolve().parent.parent.parent +# Frames from our own packages only. A frame in site-packages tells us nothing +# actionable and its path can carry a username. +_OWN_PACKAGES = ("condor", "handlers", "utils", "mcp_servers", "routines") + +# Rolling, in-memory only, never persisted and never transmitted as a set — +# only its cardinality reaches an envelope. +_seen_users: dict[str, float] = {} +_surface_counts: dict[str, int] = {} + +# A leading run of lowercase and underscores. Deliberately strict: callback data +# like ``admin:approve_12345`` or ``bots:view_MyBotName`` carries an identifier +# after the verb, and cutting at the first digit or capital drops it. +_VERB = re.compile(r"^[a-z][a-z_]{0,31}") + + +def _quiet(fn): + """A tap must never be the reason a handler failed.""" + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception: # noqa: BLE001 + log.debug("Telemetry tap %s failed", fn.__name__, exc_info=True) + return None + + return wrapper + + +def _quiet_async(fn): + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + try: + return await fn(*args, **kwargs) + except Exception: # noqa: BLE001 + log.debug("Telemetry tap %s failed", fn.__name__, exc_info=True) + return None + + return wrapper + + +def _on() -> bool: + from condor.telemetry import consent + + return consent.level() != consent.OFF + + +# ── Identity ───────────────────────────────────────────────────────────── + + +@_quiet +def user_hash(user_id) -> str | None: + """A per-install pseudonym for one Telegram user. + + Salted with the install's own secret, which never leaves the machine, so the + result counts distinct users within an install and is useless for correlating + the same person across two installs. The Telegram id itself is never sent. + """ + from condor.telemetry import consent + + secret = consent.install_secret() + if not secret or user_id is None: + return None + digest = hashlib.sha256(f"{secret}:{user_id}".encode()).hexdigest() + return digest[:16] + + +def _note_user(hashed: str | None, surface: str) -> None: + if hashed: + _seen_users[hashed] = time.time() + _surface_counts[surface] = _surface_counts.get(surface, 0) + 1 + + +@_quiet +def feature_first_use(feature: str) -> None: + """Fire once per install, ever. The activation funnel.""" + if not _on(): + return + from condor.telemetry import consent + + if consent.mark_feature_seen(feature): + emit("feature_first_use", feature=feature) + + +def _verb_of(segment: str) -> str: + match = _VERB.match((segment or "").strip()) + return (match.group(0).rstrip("_") if match else "") or "other" + + +# ── Telegram ───────────────────────────────────────────────────────────── + + +async def telegram_tap(update, context) -> None: + """Observe every update, in ``group=-1``, without ever deciding anything. + + Registered before the real handlers so it sees commands and callbacks from + unauthorized users too — who is knocking is signal. It reads authorization + state; it must never call into ``@restricted``, or a read would become an + access-control side effect. + """ + try: + if not _on(): + return + user = getattr(update, "effective_user", None) + hashed = user_hash(getattr(user, "id", None)) if user else None + authorized = _is_authorized(getattr(user, "id", None)) + + message = getattr(update, "message", None) + text = (getattr(message, "text", None) or "") if message else "" + if text.startswith("/"): + raw = text[1:].split(maxsplit=1)[0].split("@")[0].lower() + _note_user(hashed, "telegram") + emit( + "command", + name=raw, + surface="telegram", + user_hash=hashed, + authorized=authorized, + ) + if authorized: + feature_first_use(f"command:{_verb_of(raw)}") + return + + query = getattr(update, "callback_query", None) + data = (getattr(query, "data", None) or "") if query else "" + if data: + module, _, rest = data.partition(":") + _note_user(hashed, "telegram") + emit( + "action", + module=module.lower(), + verb=_verb_of(rest.split(":")[0]), + surface="telegram", + ) + except Exception: # noqa: BLE001 + log.debug("Telegram telemetry tap failed", exc_info=True) + + +@_quiet +def _is_authorized(user_id) -> bool: + from condor.telemetry.consent import _cm + + cm = _cm() + return bool(cm and user_id is not None and cm.is_approved(int(user_id))) + + +# ── Errors ─────────────────────────────────────────────────────────────── + + +def _frames(exc: BaseException) -> list[str]: + """Up to five ``file:line`` pairs from our own packages, repo-relative. + + Absolute paths are not sent: they contain the operator's home directory and + often their username. + """ + out: list[str] = [] + for frame in traceback.extract_tb(exc.__traceback__): + try: + path = Path(frame.filename).resolve().relative_to(_REPO) + except (ValueError, OSError): + continue + if path.parts and path.parts[0] in _OWN_PACKAGES or path.name == "main.py": + out.append(f"{path.as_posix()}:{frame.lineno}") + return out[-5:] + + +@_quiet +def on_error( + exc: BaseException | None, where: str, surface: str = "other", fatal: bool = False +) -> None: + """Report that something broke — the shape of it, never its words. + + Only the exception *type*, a hash of its message, and our own stack frames. + Message strings are where balances, hostnames, URLs and keys leak, so they + are hashed and discarded: grouping still works, disclosure does not. + """ + if exc is None or not _on(): + return + signature = hashlib.sha256(str(exc).encode("utf-8", "replace")).hexdigest()[:12] + emit( + "error", + where=where, + exc_type=type(exc).__name__, + sig=signature, + frames=_frames(exc), + surface=surface, + fatal=fatal, + ) + + +@_quiet +def on_upstream_error(service: str, op: str, status) -> None: + """A dependency answered badly: which one, which operation, which code.""" + emit("upstream_error", service=service, op=op, status=str(status)) + + +# ── Web ────────────────────────────────────────────────────────────────── + + +def _route_action(request) -> tuple[str, str] | None: + """Turn a matched route *template* into (module, verb). + + The template, never the URL: ``/api/v1/bots/{name}`` rather than + ``/api/v1/bots/my-arb-bot``. Cardinality is bounded by our own router, and + no path parameter — a bot name, a server name, a report id — is ever read. + """ + route = request.scope.get("route") + template = getattr(route, "path", None) + if not template: + return None + parts = [p for p in template.strip("/").split("/") if p] + if parts[:2] == ["api", "v1"]: + parts = parts[2:] + if not parts: + return None + module = parts[0].lower() + static = [p for p in parts[1:] if not p.startswith("{")] + verb = _verb_of("_".join(static)) if static else request.method.lower() + return module, verb + + +@_quiet_async +async def web_tap(request, call_next): + """ASGI middleware: one ``action`` per API call, with its status code.""" + response = await call_next(request) + try: + if _on() and str(request.url.path).startswith("/api/"): + action = _route_action(request) + if action: + module, verb = action + _surface_counts["web"] = _surface_counts.get("web", 0) + 1 + emit( + "action", + module=module, + verb=verb, + surface="web", + status=response.status_code, + ) + except Exception: # noqa: BLE001 + log.debug("Web telemetry tap failed", exc_info=True) + return response + + +# ── Agents, strategies, routines, confirmations ────────────────────────── + + +@_quiet +def agent_turn( + *, + kind: str, + provider: str = "", + model: str = "", + tool_calls: int = 0, + duration_ms: int = 0, + outcome: str = "done", + surface: str = "other", + tools: dict | None = None, +) -> None: + """One turn through ``condor.runtime.client.prompt`` — the single funnel + Telegram, the dashboard and MCP all cross.""" + _surface_counts[surface] = _surface_counts.get(surface, 0) + 1 + emit( + "agent_turn", + kind=kind, + provider=provider, + model=model, + tool_calls=tool_calls, + duration_ms=duration_ms, + outcome=outcome, + surface=surface, + tools=tools, + ) + + +@_quiet +def strategy_run(config: dict | None, *, ticks: int = 0, stopped_by: str = "") -> None: + """A tick-engine session ended. Shape of the run only — never its journal.""" + config = config or {} + emit( + "strategy_run", + mode=config.get("execution_mode", "loop"), + frequency_sec=config.get("frequency_sec"), + ticks=ticks, + bot_mode=config.get("bot_mode"), + has_risk_limits=bool(config.get("risk_limits")), + stopped_by=stopped_by, + ) + + +@_quiet +def routine_run( + *, routine: str, kind: str, trigger: str, duration_ms: int, ok: bool +) -> None: + """A routine ran. ``routine`` is the shipped name, or ``custom`` when the + file is not one of ours — a user's private routine name is theirs.""" + emit( + "routine_run", + routine=routine, + kind=kind, + trigger=trigger, + duration_ms=duration_ms, + ok=ok, + ) + + +def shipped_routine_name(name: str) -> str: + """``name`` if the routine ships in this repo, else ``custom``.""" + try: + if (_REPO / "routines" / f"{name}.py").exists(): + return name + except Exception: # noqa: BLE001 + pass + return "custom" + + +@_quiet +def confirmation(tool: str, decision: str) -> None: + emit("confirmation", tool=tool, decision=decision) + + +@_quiet +def version_change(from_version: str, to_version: str, was_behind: int = 0) -> None: + emit( + "version_change", + from_version=from_version, + to_version=to_version, + was_behind=was_behind, + ) + + +def tracked(tool_name: str): + """Decorator for an MCP tool, which runs in its own process. + + That process has no ``job_queue`` and no shared heap, so the event goes + straight to its own ``spool..jsonl`` and the host drains it later. Only + the tool name, whether it worked, and how long it took — never arguments, + never results. + """ + + def decorator(fn): + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + started = time.monotonic() + ok = True + try: + return await fn(*args, **kwargs) + except Exception: + ok = False + raise + finally: + emit( + "mcp_tool", + tool=tool_name, + ok=ok, + duration_ms=int((time.monotonic() - started) * 1000), + ) + + return wrapper + + return decorator + + +# ── Jobs ───────────────────────────────────────────────────────────────── + + +async def flush_job(context) -> None: + """The only place in the process that may talk to a collector.""" + from condor.telemetry import emitter + + await emitter.flush("job") + + +async def heartbeat_job(context) -> None: + """Proof of life from an install that did nothing else this cycle.""" + try: + if not _on(): + return + from condor.telemetry import context as ctx + + cutoff = time.time() - 24 * 3600 + for hashed, seen in list(_seen_users.items()): + if seen < cutoff: + _seen_users.pop(hashed, None) + surfaces = dict(_surface_counts) + _surface_counts.clear() + emit( + "heartbeat", + uptime_h=ctx.uptime_h(), + active_users_24h=len(_seen_users), + surfaces=surfaces, + ) + except Exception: # noqa: BLE001 + log.debug("Heartbeat tap failed", exc_info=True) + + +def register_jobs(application) -> None: + """Register the flush and heartbeat jobs, the house pattern from + ``handlers.admin.update.schedule_update_checks``. + + Registered unconditionally: the jobs are cheap, and both return immediately + when consent is absent. That keeps a mid-run opt-in working without a + restart. + """ + try: + queue = getattr(application, "job_queue", None) + if queue is None: + return + for name in (TELEMETRY_FLUSH_JOB, TELEMETRY_HEARTBEAT_JOB): + for job in queue.get_jobs_by_name(name): + job.schedule_removal() + queue.run_repeating( + flush_job, interval=FLUSH_INTERVAL_S, first=60, name=TELEMETRY_FLUSH_JOB + ) + queue.run_repeating( + heartbeat_job, + interval=HEARTBEAT_INTERVAL_S, + first=120, + name=TELEMETRY_HEARTBEAT_JOB, + ) + except Exception: # noqa: BLE001 + log.debug("Could not register telemetry jobs", exc_info=True) diff --git a/config_manager.py b/config_manager.py index 4182acab..cf2dc723 100644 --- a/config_manager.py +++ b/config_manager.py @@ -150,6 +150,7 @@ def _load_config(self): self._data.setdefault("server_access", {}) self._data.setdefault("chat_defaults", {}) self._data.setdefault("user_preferences", {}) + self._data.setdefault("telemetry", {}) # Migrate audit_log from config.yml to separate file (one-time) if "audit_log" in self._data: self._audit_log = self._data.pop("audit_log") @@ -174,6 +175,7 @@ def _default_data(self) -> dict: "server_access": {}, "chat_defaults": {}, "user_preferences": {}, + "telemetry": {}, "version": self.VERSION, } @@ -216,6 +218,7 @@ def _save_config(self): "server_access": self._data.get("server_access", {}), "chat_defaults": self._data.get("chat_defaults", {}), "web_jwt_secret": self._data.get("web_jwt_secret"), + "telemetry": self._data.get("telemetry", {}), "version": self._data.get("version", self.VERSION), } # Keep a copy of the last known-good file before truncating it, @@ -586,6 +589,27 @@ async def close_all_clients(self): # USER MANAGEMENT # ========================================================================= + # ── Telemetry (FEAT-023) ── + # Consent and the install's random identity live here because this file is + # the one durable, process-wide store the MCP subprocess can also read. + # Nothing in this section is transmitted except `install_id` and `level`; + # `install_secret` never leaves the machine. See PRIVACY.md. + + def get_telemetry(self) -> dict: + """The telemetry section. Empty dict on an install that never opted in.""" + section = self._data.get("telemetry") + return dict(section) if isinstance(section, dict) else {} + + def update_telemetry(self, **changes) -> dict: + """Merge keys into the telemetry section and persist.""" + section = self._data.setdefault("telemetry", {}) + if not isinstance(section, dict): + section = {} + self._data["telemetry"] = section + section.update(changes) + self._save_config() + return dict(section) + def get_user(self, user_id: int) -> Optional[dict]: """Get user record.""" return self._data.get("users", {}).get(user_id) diff --git a/utils/config.py b/utils/config.py index 14d7631a..c0d6e160 100644 --- a/utils/config.py +++ b/utils/config.py @@ -31,3 +31,18 @@ else: WEB_PORT = int(_web_port_raw) if _web_port_raw else 8088 WEB_URL = f"http://localhost:{WEB_PORT}" + +# ── Telemetry (FEAT-023) ── +# Opt-in and OFF by default. Nothing is collected, buffered or sent unless the +# install's admin has explicitly consented, or an operator sets CONDOR_TELEMETRY +# here. An unset value is *not* "on": it means "no override", and the stored +# consent decides — whose default is `unknown`, which emits nothing. +# off - nothing, ever. emit() returns immediately. +# ping - install / heartbeat / version_change / shutdown only. +# usage - the full allowlisted taxonomy in condor/telemetry/schema.py. +CONDOR_TELEMETRY = os.environ.get("CONDOR_TELEMETRY", "").strip().lower() or None + +# Where a batch would be POSTed. Deliberately unset by default and NOT baked +# into the source: with no URL the send path is inert, and events can only ever +# accumulate in the local capped outbox. See PRIVACY.md. +CONDOR_TELEMETRY_URL = os.environ.get("CONDOR_TELEMETRY_URL", "").strip() or None From 5412f80cc36cda91691d8cee569070cdf93965fe Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:37:51 +0300 Subject: [PATCH 014/116] Tap the seams that already exist, instead of every call site (FEAT-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrumentation goes where the codebase already funnels things, so almost no handler is touched and nothing has to be kept in sync by hand: - main.py: one TypeHandler in group -1 sees every command and every callback, because PTB dispatches each update to every group. It reads authorization state but never calls into @restricted — observing must not become an access-control side effect. The error handler reports the exception type, a hash of the message and our own stack frames; never the message. - web/app.py: one middleware, keyed on the matched route *template*, so cardinality is bounded by our own router and no path parameter is ever read. - runtime/client.py: prompt() is the one funnel Telegram, the dashboard and MCP all cross, and its finally: already runs on abandonment. Tool calls are counted by ACP `kind`, not by `title` — a title is free text and routinely contains a file path. - engine.py, routine_store.py, confirmations.py, updater.py: session shape, routine outcome, approval decision, version adoption. - mcp/server.py: the server runs in its own process with no job_queue, so its 13 tools spool to a pid-scoped file the host drains. The consent prompt rides next to the "Condor is online" message the admin already gets, since that is the one moment they are looking. Three buttons, written to disk before it is sent so a crash loop cannot re-ask forever. The web middleware deliberately lets call_next raise: swallowing it would turn an unhandled route exception into "no response returned" instead of the 500 it is. Everything else swallows, twice. --- condor/agents/engine.py | 14 +++++++++ condor/routine_store.py | 20 ++++++++++++ condor/runtime/client.py | 52 ++++++++++++++++++++++++++++++- condor/runtime/confirmations.py | 11 +++++++ condor/telemetry/taps.py | 21 +++++-------- condor/web/app.py | 7 +++++ main.py | 55 +++++++++++++++++++++++++++++++++ mcp_servers/condor/server.py | 25 +++++++++++++++ utils/updater.py | 20 ++++++++++++ 9 files changed, 210 insertions(+), 15 deletions(-) diff --git a/condor/agents/engine.py b/condor/agents/engine.py index 820cdbe4..a390fcdb 100644 --- a/condor/agents/engine.py +++ b/condor/agents/engine.py @@ -28,6 +28,7 @@ ) from condor.acp.pydantic_ai_client import PydanticAIClient, is_pydantic_ai_model from condor.runtime.registry_file import LoopState +from condor.telemetry import taps as telemetry_taps from .agent import Agent from .journal import JournalManager, next_experiment_number, next_session_number @@ -110,6 +111,9 @@ class TickEngine: _cached_routines_section: str | None = field(default=None, init=False, repr=False) _adoption_done: bool = field(default=False, init=False, repr=False) _mode_mismatch_noted: bool = field(default=False, init=False, repr=False) + # Why the loop ended, for the strategy_run telemetry event: "user" unless + # something in the loop set it first. + _last_stop_reason: str = field(default="user", init=False, repr=False) # Session canvas + live report (FEAT-036). Both None for experiments, which # keep no journal and therefore no narrative to render. _session_report: "SessionReport | None" = field( @@ -261,6 +265,13 @@ async def stop(self) -> None: # from there; the gap in between belongs to no session, which is the truth. if self.ledger is not None: self.ledger.release() + # Shape of the session for telemetry (FEAT-023): mode, cadence and tick + # count. Never the playbook, the journal, the pairs or the positions. + telemetry_taps.strategy_run( + self.config, + ticks=getattr(self.journal, "tick_count", 0) or 0, + stopped_by=self._last_stop_reason, + ) if self.journal: self.journal.close() _supervisor().unregister(self.agent_id, LoopState.STOPPED) @@ -282,6 +293,7 @@ async def _run_shutdown(self, reason: str) -> None: if self._shutting_down: return self._shutting_down = True + self._last_stop_reason = "shutdown" # Halt the loop so no next/concurrent tick fights the winddown. self._running = False self._paused = True @@ -394,6 +406,7 @@ async def _loop(self) -> None: label, ) await self._notify(f"Agent {self.agent_id}: {label} complete.") + self._last_stop_reason = "complete" self._running = False _supervisor().unregister(self.agent_id, LoopState.COMPLETED) return @@ -409,6 +422,7 @@ async def _loop(self) -> None: await self._notify( f"Agent {self.agent_id}: completed {max_ticks} ticks (max_ticks limit)." ) + self._last_stop_reason = "max_ticks" self._running = False self.journal.close() _supervisor().unregister(self.agent_id, LoopState.COMPLETED) diff --git a/condor/routine_store.py b/condor/routine_store.py index e3ff7124..63f90513 100644 --- a/condor/routine_store.py +++ b/condor/routine_store.py @@ -17,6 +17,7 @@ import condor.reports as reports from condor import routine_hooks +from condor.telemetry import taps as telemetry_taps from routines.base import ( RoutineResult, discover_routines, @@ -412,6 +413,7 @@ async def _execute_and_record( failed_status: str | None = None, fire_hooks: bool = True, agent: str = "", + trigger: str = "other", ) -> None: """Run a routine once, store the result, update instance metadata, fire hooks. @@ -479,6 +481,20 @@ async def _execute_and_record( } ) + # Usage telemetry (FEAT-023): whether it ran, how it was triggered and + # how long it took. The routine's own name only when the file ships in + # this repo — a user's private routine is called `custom` — and never + # its config, its output or its error text. + telemetry_taps.routine_run( + routine=telemetry_taps.shipped_routine_name( + (routine.name or "").split("/")[-1] + ), + kind="continuous" if getattr(routine, "continuous", False) else "oneshot", + trigger=trigger, + duration_ms=int(duration * 1000), + ok=not failed, + ) + await self._report_run(instance_id, summary, error_msg) if fire_hooks: @@ -547,6 +563,7 @@ async def _run_oneshot( server_name: str, user_id: int = 0, agent: str = "", + trigger: str = "manual", ) -> None: await self._execute_and_record( instance_id, @@ -557,6 +574,7 @@ async def _run_oneshot( status_after="completed", failed_status="failed", agent=agent, + trigger=trigger, ) async def start_continuous( @@ -618,6 +636,7 @@ async def _run_continuous( status_after="stopped", fire_hooks=False, agent=agent, + trigger="manual", ) async def schedule( @@ -670,6 +689,7 @@ async def _run_scheduled( server_name, user_id, status_after="scheduled", + trigger="schedule", ) # A cancel that lands mid-run is swallowed (and recorded) by # _execute_and_record; stop() removed the instance, so bail out diff --git a/condor/runtime/client.py b/condor/runtime/client.py index ad41437f..881f6e4b 100644 --- a/condor/runtime/client.py +++ b/condor/runtime/client.py @@ -14,13 +14,16 @@ import logging import os +import time from typing import AsyncIterator, Literal +from condor.acp.pydantic_ai_client import model_prefix from condor.runtime import conversations -from condor.runtime.events import RuntimeEvent +from condor.runtime.events import EventType, RuntimeEvent from condor.runtime.keys import SessionKey from condor.runtime.models import PromptRequest, SessionInfo, SessionSpec from condor.runtime.timeouts import TIMEOUTS +from condor.telemetry import taps as telemetry_taps log = logging.getLogger(__name__) @@ -134,6 +137,27 @@ def _deny_pending_confirmations(raw_key: str) -> None: log.info("Denied %d pending confirmation(s) on steering %s", denied, raw_key) +# Session surfaces are short codes; the telemetry taxonomy spells them out. +_SURFACES = {"tg": "telegram", "web": "web", "mcp": "mcp", "": "other"} + + +def _turn_kind(req: PromptRequest, key: SessionKey) -> str: + """Which kind of turn this was, from what the request already carries.""" + kind = (getattr(req, "user_kind", "") or "").lower() + if kind in ("consult", "delegate", "tick"): + return kind + return "chat" + + +def _model_of(agent_key: str) -> str: + """The model id without any custom-endpoint nickname. + + ``custom@venice:llama-3.3-70b`` reports ``llama-3.3-70b``: the nickname is + something the operator typed and may name their employer or their host. + """ + return agent_key.split(":", 1)[1] if ":" in agent_key else agent_key + + async def prompt( key: SessionKey, req: PromptRequest, @@ -197,12 +221,25 @@ async def prompt( # as a system note rather than as the user's words. user_kind=req.user_kind, ) + # Turn shape for telemetry (FEAT-023): counts and categories only. Nothing + # about what was asked, answered, or which file a tool touched. + started = time.monotonic() + tool_kinds: dict[str, int] = {} + outcome = "aborted" try: async for event in session.prompt_stream(req.text, lock_timeout=lock_timeout): runtime_event = RuntimeEvent.from_acp(event, session_key=raw_key) + if runtime_event.type is EventType.TOOL_CALL: + # The ACP `kind` ("read", "execute", …), never the `title`: a + # title is free text and routinely contains a file path. + kind = str(runtime_event.field("kind") or "other") + tool_kinds[kind] = tool_kinds.get(kind, 0) + 1 + elif runtime_event.type is EventType.DONE: + outcome = "done" recorder.observe(runtime_event) yield runtime_event except Exception as exc: # noqa: BLE001 - surfaced to the caller as an event + outcome = "error" log.exception("Prompt failed for session %s", raw_key) failure = RuntimeEvent.error(str(exc), session_key=raw_key) recorder.observe(failure) @@ -214,6 +251,19 @@ async def prompt( # abandoned async generator only ever gets GeneratorExit. Losing the # half-written reply is the bug this feature exists to fix. recorder.flush() + # Here for the same reason: this is the one funnel Telegram, the + # dashboard and MCP all cross, and it is the only place that sees an + # abandoned turn end. + telemetry_taps.agent_turn( + kind=_turn_kind(req, key), + provider=model_prefix(session.agent_key) or "acp", + model=_model_of(session.agent_key), + tool_calls=sum(tool_kinds.values()), + duration_ms=int((time.monotonic() - started) * 1000), + outcome=outcome, + surface=_SURFACES.get(getattr(key, "surface", ""), "other"), + tools=tool_kinds, + ) async def prompt_once(key: SessionKey, text: str) -> str: diff --git a/condor/runtime/confirmations.py b/condor/runtime/confirmations.py index c065a718..d18821e6 100644 --- a/condor/runtime/confirmations.py +++ b/condor/runtime/confirmations.py @@ -26,6 +26,7 @@ from typing import Any, Protocol from condor.runtime.timeouts import TIMEOUTS +from condor.telemetry import taps as telemetry_taps log = logging.getLogger(__name__) @@ -44,6 +45,12 @@ class ConfirmationStatus(str, Enum): TIMEOUT = "timeout" +def _tool_of(pending: "PendingConfirmation") -> str: + """The tool name out of a tool_call payload, for telemetry only.""" + call = pending.tool_call or {} + return str(call.get("name") or call.get("kind") or "unknown") + + @dataclass class PendingConfirmation: """One approval request, awaiting a human.""" @@ -184,6 +191,9 @@ async def resolve( ) pending.selected_option_id = option_id pending._event.set() + # Which tool was asked about and what was decided (FEAT-023). Never the + # summary, the arguments or who decided. + telemetry_taps.confirmation(_tool_of(pending), "allow" if approved else "deny") return True def get(self, confirmation_id: str) -> PendingConfirmation | None: @@ -236,6 +246,7 @@ def sweep(self) -> int: ): pending.status = ConfirmationStatus.TIMEOUT pending._event.set() # wake any waiter immediately + telemetry_taps.confirmation(_tool_of(pending), "timeout") elif pending.status is not ConfirmationStatus.PENDING: # Give the waiter a grace period to read the outcome first. if now - pending.expires_at > CLEANUP_INTERVAL: diff --git a/condor/telemetry/taps.py b/condor/telemetry/taps.py index 181d29b7..c32117e5 100644 --- a/condor/telemetry/taps.py +++ b/condor/telemetry/taps.py @@ -59,18 +59,6 @@ def wrapper(*args, **kwargs): return wrapper -def _quiet_async(fn): - @functools.wraps(fn) - async def wrapper(*args, **kwargs): - try: - return await fn(*args, **kwargs) - except Exception: # noqa: BLE001 - log.debug("Telemetry tap %s failed", fn.__name__, exc_info=True) - return None - - return wrapper - - def _on() -> bool: from condor.telemetry import consent @@ -251,9 +239,14 @@ def _route_action(request) -> tuple[str, str] | None: return module, verb -@_quiet_async async def web_tap(request, call_next): - """ASGI middleware: one ``action`` per API call, with its status code.""" + """ASGI middleware: one ``action`` per API call, with its status code. + + Deliberately not wrapped in a swallow-everything decorator: ``call_next`` + must be allowed to raise, or an unhandled route exception would surface as + "no response returned" instead of the 500 it is. Only the observation is + guarded. + """ response = await call_next(request) try: if _on() and str(request.url.path).startswith("/api/"): diff --git a/condor/web/app.py b/condor/web/app.py index e5da32c2..c85930f5 100644 --- a/condor/web/app.py +++ b/condor/web/app.py @@ -9,6 +9,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from condor.telemetry.taps import web_tap from condor.web.routes import ( agents, archived, @@ -62,6 +63,12 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # Usage telemetry (FEAT-023). Reports the matched route *template* + # (`/api/v1/bots/{name}`), never the URL, so cardinality is bounded by our + # own router and no path parameter — a bot name, a server name, a report id + # — is ever read. No-op unless the admin opted in. + app.middleware("http")(web_tap) + # ── API routes ── app.include_router(auth.router, prefix="/api/v1") app.include_router(servers.router, prefix="/api/v1") diff --git a/main.py b/main.py index a5d96b0e..db1b7c4c 100644 --- a/main.py +++ b/main.py @@ -15,10 +15,12 @@ CommandHandler, ContextTypes, MessageHandler, + TypeHandler, filters, ) from condor.persistence import SafePicklePersistence +from condor.telemetry import taps as telemetry_taps from handlers import cancel_command, clear_all_input_states from utils.auth import restricted from utils.config import TELEGRAM_TOKEN, WEB_PORT, WEB_URL @@ -301,6 +303,13 @@ def register_handlers(application: Application) -> None: # Clear existing handlers application.handlers.clear() + # Usage telemetry observer (FEAT-023). PTB dispatches every update to every + # group, so one handler in group -1 sees every command and every callback + # without touching a single handler below. It only reads — it must never + # call into @restricted, or observing would become an authorization side + # effect — and it is a no-op unless the admin opted in. + application.add_handler(TypeHandler(Update, telemetry_taps.telegram_tap), group=-1) + # Add command handlers application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("portfolio", portfolio_command)) @@ -378,6 +387,16 @@ def register_handlers(application: Application) -> None: CallbackQueryHandler(admin_callback_handler, pattern="^admin:") ) + # Telemetry consent prompt (FEAT-023): three buttons, admin only + from condor.telemetry import prompt as telemetry_prompt + + application.add_handler( + CallbackQueryHandler( + telemetry_prompt.callback_handler, + pattern=f"^{telemetry_prompt.CALLBACK_PREFIX}:", + ) + ) + # Add callback query handler for portfolio settings application.add_handler(get_portfolio_callback_handler()) @@ -594,6 +613,20 @@ async def startup(application: Application) -> None: schedule_update_checks(application) + # Usage telemetry (FEAT-023). init() resolves the consent level once so the + # taps never read the disk on a hot path, and only materializes the + # install's random ids when telemetry is actually on. The jobs are + # registered either way and return immediately while consent is absent, so + # opting in mid-run works without a restart. + from condor import telemetry + + try: + level = telemetry.init(hosted=True) + telemetry_taps.register_jobs(application) + logger.info("Telemetry level: %s", level) + except Exception: + logger.exception("Telemetry init failed (continuing without it)") + # Start file watcher asyncio.create_task(watch_and_reload(application)) @@ -647,6 +680,16 @@ async def teardown(application: Application) -> None: await hummingbot_client.close() + # Record the clean exit and give the outbox one last chance. Both are no-ops + # unless the admin opted in, and neither can fail the shutdown. + try: + from condor import telemetry + + telemetry.shutdown("signal") + await telemetry.flush("teardown") + except Exception: + logger.debug("Telemetry teardown failed", exc_info=True) + async def watch_and_reload(application: Application) -> None: """Watch for file changes and reload handlers automatically.""" @@ -716,9 +759,14 @@ def get_persistence() -> SafePicklePersistence: async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle errors gracefully.""" if isinstance(context.error, NetworkError): + # Expected and self-healing; reported as an upstream blip, not a bug. + telemetry_taps.on_upstream_error("telegram", "poll", "network") logger.warning(f"Network error (will retry): {context.error}") return + # Type, a hash of the message, and our own stack frames. Never the message + # itself — that is where balances, hostnames and keys leak. + telemetry_taps.on_error(context.error, where="telegram", surface="telegram") logger.exception("Exception while handling an update:", exc_info=context.error) @@ -841,6 +889,13 @@ async def _run_dual(application: Application) -> None: except Exception as e: logger.warning(f"Failed to send startup notification to admin: {e}") + # Ask, once, whether this install wants to be counted (FEAT-023). Sent + # next to the boot notification because that is the one moment the admin + # is already looking. Until it is answered, nothing is collected. + from condor.telemetry.prompt import maybe_prompt_admin + + await maybe_prompt_admin(application.bot) + logger.info("Starting Condor: Telegram bot + web dashboard on port %s", WEB_PORT) # Handle shutdown signals diff --git a/mcp_servers/condor/server.py b/mcp_servers/condor/server.py index 008f5be0..657cd025 100644 --- a/mcp_servers/condor/server.py +++ b/mcp_servers/condor/server.py @@ -6,6 +6,7 @@ from mcp.server.fastmcp import FastMCP +from condor.telemetry import taps as telemetry_taps from mcp_servers.condor.middleware import handle_errors from mcp_servers.condor.tools import available_models as available_models_tool from mcp_servers.condor.tools import consult as consult_tool @@ -282,6 +283,7 @@ def _build_instructions() -> str: @mcp.tool() @handle_errors("consult agent") +@telemetry_taps.tracked("consult") async def consult(agent: str, task: str, context: str = "") -> dict: """Consult a specialized domain agent and get its answer. @@ -303,6 +305,7 @@ async def consult(agent: str, task: str, context: str = "") -> dict: @mcp.tool() @handle_errors("delegate task") +@telemetry_taps.tracked("delegate") async def delegate( action: str, agent: str = "", @@ -359,6 +362,7 @@ async def delegate( @mcp.tool() @handle_errors("send notification") +@telemetry_taps.tracked("send_notification") async def send_notification( text: str, parse_mode: str = "Markdown", @@ -377,6 +381,7 @@ async def send_notification( @mcp.tool() @handle_errors("manage routines") +@telemetry_taps.tracked("manage_routines") async def manage_routines( action: str, name: str | None = None, @@ -451,6 +456,7 @@ async def manage_routines( @mcp.tool() @handle_errors("manage servers") +@telemetry_taps.tracked("manage_servers") async def manage_servers( action: str, name: str | None = None, @@ -473,6 +479,7 @@ async def manage_servers( @mcp.tool() @handle_errors("get user context") +@telemetry_taps.tracked("get_user_context") async def get_user_context() -> dict: """Get the current user's context within Condor. @@ -487,6 +494,7 @@ async def get_user_context() -> dict: @mcp.tool() @handle_errors("get available models") +@telemetry_taps.tracked("get_available_models") async def get_available_models( openrouter_query: str = "", openrouter_limit: int = 20 ) -> dict: @@ -543,6 +551,7 @@ async def get_available_models( @mcp.tool() @handle_errors("manage trading agent") +@telemetry_taps.tracked("manage_trading_agent") async def manage_trading_agent( action: str, agent_id: str | None = None, @@ -657,6 +666,7 @@ async def manage_trading_agent( @mcp.tool() @handle_errors("manage memory") +@telemetry_taps.tracked("manage_memory") async def manage_memory( action: str, name: str | None = None, @@ -710,6 +720,7 @@ async def manage_memory( @mcp.tool() @handle_errors("manage skill") +@telemetry_taps.tracked("manage_skill") async def manage_skill( action: str, name: str | None = None, @@ -818,6 +829,7 @@ async def manage_skill( @mcp.tool() @handle_errors("manage notes") +@telemetry_taps.tracked("manage_notes") async def manage_notes( action: str, key: str | None = None, @@ -855,6 +867,7 @@ async def manage_notes( @mcp.tool() @handle_errors("journal read") +@telemetry_taps.tracked("trading_agent_journal_read") async def trading_agent_journal_read( agent_id: str, section: str = "recent", @@ -882,6 +895,7 @@ async def trading_agent_journal_read( @mcp.tool() @handle_errors("journal write") +@telemetry_taps.tracked("trading_agent_journal_write") async def trading_agent_journal_write( agent_id: str, entry_type: str, @@ -933,4 +947,15 @@ async def trading_agent_journal_write( if __name__ == "__main__": + # This server runs in its own process, spawned by the agent: a different + # interpreter, a different heap, and no job_queue. hosted=False makes emit() + # append to `spool..jsonl`, which the host process drains and deletes. + # Still a no-op unless the install opted in. + try: + from condor import telemetry + + telemetry.init(hosted=False) + except Exception: # noqa: BLE001 - never block the server on telemetry + pass + mcp.run() diff --git a/utils/updater.py b/utils/updater.py index e5e9df78..63692b04 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -179,11 +179,31 @@ async def pull_updates(repo_dir: str = CONDOR_DIR) -> tuple[bool, str]: "Cannot update: there are uncommitted changes. Please commit or stash first.", ) + # The sha we are leaving, so a successful pull can report what it moved. + before = await get_local_commit(repo_dir) + # Pull rc, output = await _run_git("pull", "origin", branch, repo_dir=repo_dir) if rc != 0: return False, f"Pull failed:\n{output}" + # Version adoption telemetry (FEAT-023): two short shas and how far behind + # this install had drifted. Only for the Condor repo itself, and a no-op + # unless the admin opted in. + try: + after = await get_local_commit(repo_dir) + if repo_dir == CONDOR_DIR and before and after and before != after: + _, behind = await _run_git( + "rev-list", "--count", f"{before}..{after}", repo_dir=repo_dir + ) + from condor.telemetry import taps as telemetry_taps + + telemetry_taps.version_change( + before, after, int(behind) if behind.strip().isdigit() else 0 + ) + except Exception: + logger.debug("Could not record version change", exc_info=True) + return True, output From e9e93618b050742b56cef24430f28a7939172a59 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Tue, 11 Aug 2026 23:42:11 +0300 Subject: [PATCH 015/116] Say plainly what telemetry collects, and prove it in tests (FEAT-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRIVACY.md is the deliverable a suspicious user actually reads: a table of what is collected, a longer table of what never is, where it goes (nowhere, without a configured endpoint), and three ways to turn it off — including a one-line command that prints the authoritative answer. The README links it from the block that already opens the file with a security warning. tests/test_telemetry.py is the other half, because a privacy claim that is not asserted is a promise. The load-bearing tests are the negative ones: - a fresh install with no consent emits nothing and creates no directory, at the emitter and again at the Telegram seam; - every declared event is fed a props dict stuffed with amounts, balances, keys, wallets, pairs, server URLs, user ids, prompts and a 10 KB string, and none of it survives sanitize(); - an exception's message never appears in its event, only the type and a hash; - `admin:approve_843214321` reports `approve`; - emit() returns cleanly from an unknown event, a repr that raises, None props and a thread with no event loop — and a schema that throws cannot reach the caller, which is the blast-radius guarantee main.py depends on. Also a settings endpoint so the answer is reversible without editing YAML: GET/PUT /api/v1/settings/telemetry, admin only, and 409 when CONDOR_TELEMETRY pins the level from the environment. Turning it off is a withdrawal — the buffer and the outbox are deleted, not merely ignored. --- PRIVACY.md | 143 ++++++++++ README.md | 6 + condor/telemetry/prompt.py | 12 +- condor/web/routes/settings.py | 56 ++++ tests/test_telemetry.py | 514 ++++++++++++++++++++++++++++++++++ 5 files changed, 725 insertions(+), 6 deletions(-) create mode 100644 PRIVACY.md create mode 100644 tests/test_telemetry.py diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..3cfc426f --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,143 @@ +# Privacy + +Condor is self-hosted. It runs on your machine, holds your exchange API keys, +and places your orders. So the default for anything that could leave that +machine is **off**, and this document is the complete statement of what the one +optional exception does. + +**Short version:** a fresh install sends nothing. It has no telemetry consent +recorded, which resolves to level `off`, at which the emitter returns before it +has looked at its arguments — no buffer, no file, no directory. Nothing changes +until an admin taps "yes" on a prompt. Even then, this repository has no +collector address compiled into it: with `CONDOR_TELEMETRY_URL` unset, the send +path is inert and events can only ever reach a capped local file. + +The whole mechanism is about 900 lines in [`condor/telemetry/`](condor/telemetry/). +It is meant to be read, not trusted. + +--- + +## What is collected, if you opt in + +There are three levels. You choose one; you can change it later. + +| Level | What it sends | +|---|---| +| `off` | Nothing, ever. The emitter is a no-op. **This is the default.** | +| `ping` | Only that this install exists: `install`, `heartbeat`, `version_change`, `shutdown`. | +| `usage` | The above plus the feature, reliability and agent events below. | + +Every batch carries one context block describing the *deployment*, not you: + +| Field | Example | Why | +|---|---|---| +| `install_id` | a random UUID | Count installs and retention. Generated once, from `uuid4`. Not derived from your MAC, hostname, username, or any token. | +| `app.version`, `app.branch` | `54ad4dc`, `main` | Which commit is actually running, so we know what to support. | +| `app.python`, `app.os`, `app.arch`, `app.in_docker` | `3.12`, `linux`, `arm64`, `true` | What to test against. | +| `config.*` | `has_gateway: true`, `user_count: 3`, `server_count: 2`, `llm_providers: ["openai"]` | Counts and capability flags. Numbers and fixed provider *names* — never a server name, never a URL, never a key. | +| `dropped` | `0` | How many events the rate limiter discarded. An honest count, so a quiet incident is visibly quiet. | + +And the events themselves: + +| Event | What it carries | +|---|---| +| `command` | Which of our ~20 commands was used (anything else is `other`), the surface, and whether the sender was an approved user. | +| `action` | A module (`bots`, `dex`, `agents`, …) and a verb (`view`, `deploy`, `start`). On the web, derived from the matched route *template* — `/api/v1/bots/{name}`, never the URL. | +| `feature_first_use` | The first time this install ever uses a feature. | +| `bot_deploy`, `executor_deploy`, `trade` | Connector name, controller/executor type, side, order type, paper-or-not. | +| `routine_run` | The routine's name **only if it ships in this repo** — a routine you wrote is reported as `custom` — plus how it was triggered, how long it took, and whether it worked. | +| `error` | The exception *type*, a SHA-256 hash of its message, and up to five `file:line` frames from our own packages, relative to the repo root. | +| `upstream_error` | Which dependency (`hb_api`, `gateway`, `llm`, `telegram`), which operation, which status code. | +| `agent_turn` | Provider and model id, tool-call count by category, duration, outcome. | +| `mcp_tool` | Which MCP tool ran, whether it worked, how long it took. | +| `strategy_run` | Execution mode, tick frequency, tick count, whether risk limits were configured, why it stopped. | +| `confirmation` | Which tool was asked about, and whether it was allowed, denied, or timed out. | +| `heartbeat` | Uptime, a *count* of distinct users active in the last 24h, and a per-surface activity count. | + +## What is never collected + +This is not a policy the call sites are asked to respect. Every event and every +property is declared in [`condor/telemetry/schema.py`](condor/telemetry/schema.py), +and anything undeclared is **dropped** on the way in. Free-form strings are +truncated to 64 characters and stripped to identifier-shaped characters, so +nothing long enough to be a key, an address, a prompt or a URL survives intact. +[`tests/test_telemetry.py`](tests/test_telemetry.py) asserts it. + +Never sent, under any level: + +- **Secrets** — API keys, secret keys, passphrases, private keys, your Telegram bot token. +- **Money** — order amounts, balances, portfolio value, PnL, position sizes, leverage. +- **Positions** — trading pairs. Deliberately excluded: an install that trades one pair, plus event timestamps, is a deanonymizable disclosure of what you hold. Connector *names* are sent; pairs are not. +- **Addresses and identifiers** — wallet addresses, order ids, transaction hashes. +- **Your infrastructure** — server names, URLs, hostnames, IP addresses, file paths outside this repo, your home directory or username. +- **People** — Telegram user ids, usernames, chat ids, display names. +- **Content** — prompts, agent replies, journal entries, notes, routine configs, report bodies, and exception *message* strings. + +Two of those deserve a note, because they are where this kind of thing usually +leaks: + +- **Exception messages are hashed, not sent.** A message string is the single + most common carrier of a balance, a hostname or a key. We send + `sha256(message)[:12]`, which groups identical errors together and discloses + nothing. +- **Agent tool calls are counted by category, not by title.** A tool call's + title is free text and routinely contains a file path. We count the ACP + `kind` (`read`, `execute`, …) instead. + +### The one derived identifier + +`user_hash` lets the collector count how many distinct people use an install +without knowing who they are. It is `sha256(install_secret + telegram_user_id)`, +truncated to 16 characters, where `install_secret` is a random UUID that **never +leaves your machine**. The Telegram id is never sent, and because the salt is +per-install, the same person on two installs produces two unrelated hashes. + +## Where it goes + +Nowhere, unless you configure a destination. + +No collector URL is compiled into this repository. Events are buffered in memory +and, when a batch cannot be delivered, appended to +`condor/.runtime/telemetry/outbox.jsonl`, which is capped at 5,000 events and 7 +days — oldest dropped. With `CONDOR_TELEMETRY_URL` unset, that is the entire +life cycle: a local file, capped, that you can delete. + +You can read exactly what would be sent: + +```bash +cat condor/.runtime/telemetry/outbox.jsonl | jq . +``` + +## How to turn it off — or check it is off + +It is already off unless you turned it on. To be certain: + +```bash +# The authoritative answer. Prints "off" on a default install. +uv run python -c "from condor.telemetry import consent; print(consent.level())" +``` + +Three ways to control it, in order of precedence: + +1. **Environment** — `CONDOR_TELEMETRY=off` in your `.env`. This overrides + everything, in both directions, and is the right answer for a headless or + containerized install. `CONDOR_TELEMETRY_URL` is what enables sending at all; + leave it unset and nothing can be transmitted. +2. **The dashboard API** — `PUT /api/v1/settings/telemetry?level=off` (admin + only). `GET` the same path to see the current state. +3. **`config.yml`** — edit the `telemetry` section directly: + + ```yaml + telemetry: + consent: denied + level: off + ``` + +Turning it off is a **withdrawal, not a pause**: the in-memory buffer and the +outbox file are deleted, so nothing already recorded can be sent afterwards. + +## Changes to this document + +Adding anything to the collected list requires a change to `schema.py`, a change +to this file, and re-asking for consent. In particular, adding trading pairs +would make positions inferable from timing and must not be done quietly. diff --git a/README.md b/README.md index 663a40be..1728aab6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ A Telegram bot for monitoring and trading with Hummingbot via the **Hummingbot A > > Full walkthrough: [Securing Condor and Hummingbot API with Tailscale](https://hummingbot.org/blog/posts/securing-condor-and-hummingbot-api-with-tailscale/) · [Hummingbot API Tailscale guide](https://hummingbot.org/hummingbot-api/tailscale/) +> **Privacy:** Condor collects nothing by default. It ships with usage telemetry +> **off** and no collector address, and it stays that way unless an admin +> explicitly opts in from the one prompt it sends on first boot. What that +> option would and would not collect — and how to verify it is off — is spelled +> out in [PRIVACY.md](PRIVACY.md). + ## Features - **Portfolio Dashboard** - Comprehensive portfolio view with PNL tracking, 24h changes, and graphical analysis diff --git a/condor/telemetry/prompt.py b/condor/telemetry/prompt.py index 9c184637..dd3ee095 100644 --- a/condor/telemetry/prompt.py +++ b/condor/telemetry/prompt.py @@ -23,8 +23,8 @@ "Never included: API keys, wallet addresses, server names or URLs, " "trading pairs, amounts, balances, positions, prompts or agent replies, " "and no Telegram id or username.\n\n" - "Full details in PRIVACY.md at the root of the repo. " - "You can change this any time from the dashboard settings." + "Full details in PRIVACY.md at the root of the repo, which also says how " + "to change or withdraw this answer at any time." ) @@ -113,15 +113,15 @@ async def callback_handler(update, context) -> None: if chosen == consent.PING: await query.edit_message_text( "Thanks. Condor will only report that this install exists and " - "which version it runs. Change it any time in the dashboard " - "settings; details in PRIVACY.md." + "which version it runs. PRIVACY.md says how to change or " + "withdraw this." ) else: await query.edit_message_text( "Thanks. Condor will send anonymous usage and reliability " "events. No keys, addresses, pairs, amounts or prompts ever " - "leave this machine. Change it any time in the dashboard " - "settings; details in PRIVACY.md." + "leave this machine. PRIVACY.md says how to change or withdraw " + "this." ) except Exception: # noqa: BLE001 log.exception("Telemetry consent callback failed") diff --git a/condor/web/routes/settings.py b/condor/web/routes/settings.py index ba8e3676..cd82f4ad 100644 --- a/condor/web/routes/settings.py +++ b/condor/web/routes/settings.py @@ -607,3 +607,59 @@ async def delete_custom_provider( if not remove_custom_provider(load_user_data_for(user.id), name): raise HTTPException(status_code=404, detail=f"No saved endpoint '{name}'") return {"deleted": True, "name": name} + + +# ── Telemetry (FEAT-023) ── + + +@router.get("/telemetry") +async def get_telemetry_settings(user: WebUser = Depends(get_current_user)): + """What this install has agreed to, and whether it could send anything. + + ``endpoint_configured`` is the honest answer to "is this thing on": with no + ``CONDOR_TELEMETRY_URL`` set — the shipped state — nothing can be + transmitted no matter what the consent says, and events only accumulate in + a capped local file. + """ + from condor.telemetry import consent, emitter, outbox + + return { + "consent": consent.state(), + "level": consent.level(), + "env_overridden": consent.env_overridden(), + "endpoint_configured": bool(outbox.endpoint()), + "pending_events": emitter.buffered(), + "privacy_doc": "PRIVACY.md", + } + + +@router.put("/telemetry") +async def set_telemetry_settings( + level: str = Query(..., description="off | ping | usage"), + user: WebUser = Depends(get_current_user), +): + """Change the install's telemetry level. Admin only, and reversible. + + Setting ``off`` is a withdrawal, not a pause: the buffer and the outbox are + deleted, so nothing already recorded can be sent later. + """ + from condor.telemetry import consent + + cm = get_config_manager() + if not cm.is_admin(user.id): + raise HTTPException( + status_code=403, + detail="Telemetry is an install-wide setting; only the admin can change it", + ) + if level not in consent.LEVELS: + raise HTTPException( + status_code=400, detail=f"level must be one of {', '.join(consent.LEVELS)}" + ) + if consent.env_overridden(): + raise HTTPException( + status_code=409, + detail="CONDOR_TELEMETRY is set in the environment and overrides this setting", + ) + + applied = consent.set_level(level) + return {"level": applied, "consent": consent.state()} diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 00000000..739844a1 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,514 @@ +"""Telemetry (FEAT-023) — the tests that make the privacy promise checkable. + +The interesting assertions here are the negative ones. Most of this file exists +to prove that things do *not* happen: that a default install emits nothing, that +forbidden values cannot reach an envelope even when a caller hands them over, +and that a tap can never take the host down with it. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time + +import pytest + +from condor.telemetry import consent, context, emitter, outbox, schema, taps + + +@pytest.fixture +def install(tmp_path, monkeypatch): + """An isolated install: its own config.yml, its own runtime dir, no env.""" + import config_manager as cm_module + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("utils.config.CONDOR_TELEMETRY", None, raising=False) + monkeypatch.setattr("utils.config.CONDOR_TELEMETRY_URL", None, raising=False) + monkeypatch.setattr( + "condor.agents.agent._DATA_ROOT", str(tmp_path / "data"), raising=False + ) + cm_module.ConfigManager.reset_instance() + emitter.discard_buffer() + emitter.set_hosted(True) + consent.refresh() + yield tmp_path + cm_module.ConfigManager.reset_instance() + emitter.discard_buffer() + consent.refresh() + + +def _grant(level="usage"): + from config_manager import get_config_manager + + get_config_manager() # materialize config.yml in the tmp cwd + consent.grant(level) + + +# ── The default is silence ─────────────────────────────────────────────── + + +def test_a_fresh_install_is_off_and_stays_off(install): + """No consent recorded means no telemetry — not "buffered until asked".""" + assert consent.state() == consent.UNKNOWN + assert consent.level() == consent.OFF + + for name in schema.EVENTS: + emitter.emit(name, surface="telegram") + + assert emitter.buffered() == 0 + assert emitter.dropped() == 0 + + +def test_nothing_is_written_to_disk_when_off(install): + """Acceptance criterion: no condor/.runtime/telemetry directory is created.""" + emitter.set_hosted(False) # the spooling path, which is the one that does I/O + emitter.emit("command", name="portfolio", surface="telegram") + assert not outbox.root().exists() + assert not (install / "config.yml").exists() + + +def test_env_off_overrides_a_granted_consent(install, monkeypatch): + _grant("usage") + assert consent.level() == consent.USAGE + + monkeypatch.setattr("utils.config.CONDOR_TELEMETRY", "off", raising=False) + consent.refresh() + + assert consent.level() == consent.OFF + emitter.emit("command", name="bots", surface="telegram") + assert emitter.buffered() == 0 + + +def test_a_nonsense_env_level_is_ignored_not_obeyed(install, monkeypatch): + monkeypatch.setattr("utils.config.CONDOR_TELEMETRY", "yes-please", raising=False) + consent.refresh() + assert consent.level() == consent.OFF + + +def test_ping_level_sends_only_the_adoption_events(install): + _grant("ping") + emitter.emit("heartbeat", uptime_h=1.0) + emitter.emit("command", name="portfolio", surface="telegram") + emitter.emit("trade", venue="cex", connector="binance", side="buy") + + names = [e["name"] for e in emitter.drain()[0]] + assert names == ["heartbeat"] + + +# ── Nothing transmits ──────────────────────────────────────────────────── + + +def test_flush_is_a_no_op_without_consent(install, monkeypatch): + """A full session of activity must produce zero outbound requests.""" + calls = [] + monkeypatch.setattr(outbox, "post", lambda env: calls.append(env)) + + for _ in range(50): + emitter.emit("command", name="portfolio", surface="telegram") + taps.on_error(RuntimeError("boom"), where="test") + + assert asyncio.run(emitter.flush("test")) == 0 + assert calls == [] + assert not outbox.root().exists() + + +def test_no_endpoint_means_events_only_ever_reach_a_local_file(install): + """The shipped state: consent granted, but nowhere to send. Nothing leaves.""" + _grant("usage") + emitter.emit("command", name="portfolio", surface="telegram") + + assert outbox.endpoint() is None + assert asyncio.run(emitter.flush("test")) == 0 + + stashed = outbox.take_stashed() + assert [e["name"] for e in stashed] == ["command"] + + +def test_undelivered_events_are_retried_and_never_duplicated(install, monkeypatch): + _grant("usage") + monkeypatch.setattr(outbox, "endpoint", lambda: "http://collector.invalid") + + attempts = [] + + async def failing_post(envelope): + attempts.append(envelope) + return False + + monkeypatch.setattr(outbox, "post", failing_post) + emitter.emit("command", name="bots", surface="telegram") + assert asyncio.run(emitter.flush("first")) == 0 + + sent = [] + + async def ok_post(envelope): + sent.append(envelope) + return True + + monkeypatch.setattr(outbox, "post", ok_post) + assert asyncio.run(emitter.flush("retry")) == 1 + + ids = [e["id"] for env in sent for e in env["events"]] + assert len(ids) == len(set(ids)) == 1 + + +def test_the_outbox_respects_its_cap(install): + _grant("usage") + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + events = [ + {"id": str(i), "ts": now, "name": "command", "props": {}} + for i in range(outbox.MAX_OUTBOX_EVENTS + 250) + ] + outbox.stash(events) + assert len(outbox.take_stashed()) == outbox.MAX_OUTBOX_EVENTS + + +def test_denying_consent_destroys_what_was_collected(install): + _grant("usage") + emitter.emit("command", name="portfolio", surface="telegram") + outbox.stash( + [ + { + "id": "x", + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "name": "command", + } + ] + ) + assert outbox.outbox_path().exists() + + consent.deny() + + assert consent.level() == consent.OFF + assert emitter.buffered() == 0 + assert not outbox.outbox_path().exists() + + +# ── The leak test ──────────────────────────────────────────────────────── + +FORBIDDEN = { + "amount": 12345.67, + "balance": 98765.43, + "pnl": -420.0, + "api_key": "sk-live-DEADBEEFCAFE", + "secret_key": "topsecret", + "wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "wallet_address": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "trading_pair": "SOL-USDC", + "pair": "BTC-USDT", + "server_url": "http://10.0.0.4:8000", + "server_name": "my-vps", + "hostname": "trading-box.local", + "user_id": 843214321, + "username": "federico", + "chat_id": 12345, + "prompt": "buy me some sol", + "reply": "sure thing", + "order_id": "OID-99182", + "message": "Insufficient balance: 12.5 SOL", + "big": "x" * 10_000, +} + + +def test_no_forbidden_value_survives_any_declared_event(install): + """Feed every event a props dict stuffed with everything we promised never + to collect, and assert none of it comes out the other side.""" + _grant("usage") + + for name in schema.EVENTS: + clean = schema.sanitize(name, dict(FORBIDDEN)) + blob = json.dumps(clean) + + for key, value in FORBIDDEN.items(): + assert key not in clean, f"{name} let through the key {key!r}" + assert str(value) not in blob, f"{name} let through the value of {key!r}" + + for value in clean.values(): + if isinstance(value, str): + assert len(value) <= schema.MAX_STR + if isinstance(value, list): + assert all(len(v) <= schema.MAX_STR for v in value) + + +def test_an_undeclared_property_is_dropped_not_passed_through(install): + clean = schema.sanitize( + "trade", + {"venue": "cex", "connector": "binance", "amount": 5.0, "pair": "SOL-USDC"}, + ) + assert clean == {"venue": "cex", "connector": "binance"} + + +def test_an_unknown_enum_value_is_snapped_to_other(install): + clean = schema.sanitize( + "command", {"name": "totally_made_up", "surface": "carrier_pigeon"} + ) + assert clean == {"name": "other", "surface": "other"} + + +def test_a_long_free_form_string_is_truncated(install): + clean = schema.sanitize("upstream_error", {"service": "llm", "op": "z" * 500}) + assert len(clean["op"]) == schema.MAX_STR + + +# ── emit() never raises ────────────────────────────────────────────────── + + +class Unserializable: + def __repr__(self): # pragma: no cover - must never be reached in a payload + raise RuntimeError("even repr explodes") + + +def test_emit_survives_everything_a_caller_can_do_to_it(install): + _grant("usage") + + emitter.emit("no_such_event_name", whatever=1) + emitter.emit("command", name=Unserializable(), surface=Unserializable()) + emitter.emit("command", **{}) + emitter.emit("error", frames=None, sig=None, where=None) + + # And from a thread that has no event loop at all. + failures = [] + + def in_thread(): + try: + emitter.emit("command", name="portfolio", surface="telegram") + except BaseException as exc: # pragma: no cover - the assertion is below + failures.append(exc) + + thread = threading.Thread(target=in_thread) + thread.start() + thread.join() + assert failures == [] + + +def test_a_broken_schema_cannot_break_the_caller(install, monkeypatch): + """The blast-radius test: telemetry failing must never propagate.""" + _grant("usage") + + def explode(*_a, **_kw): + raise ValueError("schema is on fire") + + monkeypatch.setattr(schema, "sanitize", explode) + emitter.emit("command", name="portfolio", surface="telegram") # must not raise + + monkeypatch.setattr(consent, "level", explode) + emitter.emit("command", name="portfolio", surface="telegram") # must not raise + + # Undone here, not at teardown: the `install` fixture unwinds after + # monkeypatch and would otherwise call the exploding stub itself. + monkeypatch.undo() + + +def test_a_broken_tap_cannot_break_a_handler(install, monkeypatch): + _grant("usage") + monkeypatch.setattr(taps, "emit", lambda *a, **kw: 1 / 0) + + taps.on_error(RuntimeError("x"), where="test") + taps.confirmation("manage_bots", "allow") + taps.strategy_run({"execution_mode": "loop"}, ticks=3) + taps.routine_run( + routine="x", kind="oneshot", trigger="manual", duration_ms=1, ok=True + ) + taps.agent_turn(kind="chat") + taps.version_change("aaa", "bbb", 2) + + +def test_the_web_tap_lets_route_exceptions_through(install): + """Swallowing here would turn a 500 into "no response returned".""" + _grant("usage") + + class Req: + url = type("U", (), {"path": "/api/v1/bots"})() + method = "GET" + scope = {} + + async def boom(_request): + raise RuntimeError("the route failed") + + with pytest.raises(RuntimeError, match="the route failed"): + asyncio.run(taps.web_tap(Req(), boom)) + + +# ── The taps report shapes, not content ────────────────────────────────── + + +def test_an_error_reports_its_type_and_a_hash_never_its_message(install): + _grant("usage") + secret = "Insufficient balance 12.5 SOL on wallet 7xKXtg2CW87d97TXJSD" + try: + raise ValueError(secret) + except ValueError as exc: + taps.on_error(exc, where="handlers.trading", surface="telegram") + + event = emitter.drain()[0][0] + blob = json.dumps(event) + assert event["props"]["exc_type"] == "ValueError" + assert secret not in blob + assert "12.5" not in blob + assert len(event["props"]["sig"]) == 12 + # Frames are repo-relative, so no home directory and no username. + for frame in event["props"].get("frames", []): + assert not frame.startswith("/") + + +def test_a_callback_verb_drops_the_identifier_after_it(install): + """`admin:approve_12345` must report `approve`, never the id.""" + assert taps._verb_of("approve_12345") == "approve" + assert taps._verb_of("view_MyPrivateBotName") == "view" + assert taps._verb_of("set_slippage") == "set_slippage" + + +def test_the_user_hash_is_not_the_telegram_id_and_is_install_scoped(install): + _grant("usage") + first = taps.user_hash(843214321) + assert first and "843214321" not in first + assert len(first) == 16 + assert taps.user_hash(843214321) == first + + # A different install secret yields a different hash for the same person. + consent._update(install_secret="a-completely-different-secret") + assert taps.user_hash(843214321) != first + + +def test_a_private_routine_is_reported_as_custom(install): + assert taps.shipped_routine_name("definitely_not_a_shipped_routine") == "custom" + + +def test_the_install_context_carries_no_identity(install): + _grant("usage") + blob = json.dumps(context.envelope([], 0, "usage")) + import getpass + import socket + + for identifying in (socket.gethostname(), getpass.getuser(), str(install)): + if identifying: + assert identifying not in blob + + +# ── Out of process ─────────────────────────────────────────────────────── + + +def test_a_process_without_a_flush_job_spools_to_its_own_file(install): + """The MCP server has its own interpreter and no job_queue; an in-memory + ring there would be silently lost.""" + _grant("usage") + emitter.set_hosted(False) + emitter.emit("mcp_tool", tool="manage_bots", ok=True, duration_ms=12) + + assert emitter.buffered() == 0 + assert outbox.spool_path().exists() + + emitter.set_hosted(True) + drained = outbox.drain_spools() + assert [e["name"] for e in drained] == ["mcp_tool"] + assert not outbox.spool_path().exists() + + +# ── Cost and limits ────────────────────────────────────────────────────── + + +def test_the_rate_limiter_confesses_instead_of_flooding(install): + _grant("usage") + for _ in range(emitter.RATE_PER_MIN + 40): + emitter.emit("error", where="loop", exc_type="ValueError", sig="abc") + + events, dropped = emitter.drain() + assert len(events) == emitter.RATE_PER_MIN + assert dropped == 40 + + +def test_emit_costs_under_50_microseconds_on_the_hot_path(install): + _grant("usage") + iterations = 2000 + started = time.perf_counter() + for _ in range(iterations): + emitter.emit("command", name="portfolio", surface="telegram") + per_call = (time.perf_counter() - started) / iterations + assert per_call < 50e-6, f"emit() took {per_call * 1e6:.1f}us" + + +def test_emit_is_free_when_off(install): + """The gate a trading path actually pays for.""" + iterations = 2000 + started = time.perf_counter() + for _ in range(iterations): + emitter.emit("command", name="portfolio", surface="telegram") + per_call = (time.perf_counter() - started) / iterations + assert per_call < 50e-6 + + +def test_the_ring_cannot_grow_without_bound(install): + _grant("usage") + for _ in range(emitter.RING_MAX * 2): + emitter._buffer.append({"id": "x", "name": "command", "props": {}}) + assert emitter.buffered() == emitter.RING_MAX + + +# ── The Telegram seam ──────────────────────────────────────────────────── + + +class _FakeUpdate: + """Just enough of an Update for the tap, which only ever reads.""" + + def __init__(self, text: str = "", callback: str = "", user_id: int = 55): + self.effective_user = type("U", (), {"id": user_id})() + self.message = type("M", (), {"text": text})() if text else None + self.callback_query = type("C", (), {"data": callback})() if callback else None + + +def test_the_telegram_tap_reports_the_command_never_the_message(install): + _grant("usage") + asyncio.run(taps.telegram_tap(_FakeUpdate(text="/trade SOL-USDC buy 12.5"), None)) + + events = [e for e in emitter.drain()[0] if e["name"] == "command"] + assert len(events) == 1 + blob = json.dumps(events[0]) + assert events[0]["props"]["name"] == "trade" + assert "SOL-USDC" not in blob and "12.5" not in blob + + +def test_an_unknown_command_does_not_widen_the_value_space(install): + _grant("usage") + asyncio.run(taps.telegram_tap(_FakeUpdate(text="/some_private_fork_command"), None)) + events = [e for e in emitter.drain()[0] if e["name"] == "command"] + assert events[0]["props"]["name"] == "other" + + +def test_a_callback_reports_module_and_verb_only(install): + _grant("usage") + asyncio.run( + taps.telegram_tap(_FakeUpdate(callback="admin:approve_843214321"), None) + ) + event = emitter.drain()[0][0] + assert event["name"] == "action" + assert event["props"] == { + "module": "admin", + "verb": "approve", + "surface": "telegram", + } + assert "843214321" not in json.dumps(event) + + +def test_the_telegram_tap_emits_nothing_before_consent(install): + """The acceptance criterion, at the seam rather than at the emitter.""" + assert consent.state() == consent.UNKNOWN + for update in ( + _FakeUpdate(text="/portfolio"), + _FakeUpdate(text="/keys"), + _FakeUpdate(callback="bots:deploy_mybot"), + ): + asyncio.run(taps.telegram_tap(update, None)) + assert emitter.buffered() == 0 + assert not outbox.root().exists() + + +def test_the_telegram_tap_never_raises_into_the_handler(install): + _grant("usage") + + class Hostile: + @property + def effective_user(self): + raise RuntimeError("nope") + + asyncio.run(taps.telegram_tap(Hostile(), None)) # must not raise From a4e5f48134c0cd6956dddc71ada71e7e487508f4 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 00:00:38 +0300 Subject: [PATCH 016/116] Accept an envelope from an install, once and only once (FEAT-024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FEAT-023 gave every consenting install a batched, anonymous event stream and nowhere to send it. This is the receiving end: POST /v1/events, GET /health, and the four tables underneath them. ingest.py imports condor.telemetry.schema — the *same* module the emitter sanitises with, not a copy. That is the entire reason the collector lives in this repo rather than its own. A taxonomy that drifts between emitter and validator is how this kind of system fails: the client starts sending a property, the server silently drops it, and nobody notices for three months. The endpoint is public and unauthenticated, because installs are anonymous by design and there is no credential to issue without creating the identity we explicitly do not want. So the pipeline is ordered cheapest-first and every step assumes the caller is hostile: - 1 MB body cap, counted as the stream arrives. Content-Length is checked first because it is free, but not trusted — a chunked request can understate it. - Token bucket per source IP before the JSON parser runs, then per install_id before any database contact. X-Forwarded-For is ignored unless a proxy is declared trusted, since otherwise it is a one-header limit bypass. The limiter's key space is capped and evicts LRU: install_id is attacker-chosen, and an unbounded bucket-per-id map is a memory exhaustion primitive. - Unknown schema versions, unknown levels, malformed ids and batches over 500 events are refused whole. Unknown fields inside app/config are dropped rather than stored. - No payload value is ever formatted into SQL, and no error response echoes any part of the request — every refusal is a constant, so this cannot be turned into a reflector or used as an oracle. An unknown event name or an out-of-spec property is dropped and counted, and the rest of the batch still lands. Rejecting an envelope over one bad event would let a single client bug erase a day of otherwise good data; the `rejected` counter is what makes client/server skew visible instead of silent. install_days is written on the ingest path, from newly inserted rows only, so a retry after a timeout cannot inflate a metric even though it legitimately re-sends the batch. It is also what survives the 90-day raw retention. store.py speaks Postgres and SQLite from one set of statements, differing only in placeholder style and a jsonb cast. Postgres is what gets deployed; SQLite is what lets the collector's tests run inside `uv run pytest` with no daemon, against the same SQL production executes. asyncpg sits behind a new telemetry-server extra and is imported nowhere else, so a bot that only emits telemetry never installs a database driver. --- pyproject.toml | 3 + telemetry_server/__init__.py | 22 + telemetry_server/app.py | 90 +++++ telemetry_server/config.py | 49 +++ telemetry_server/ingest.py | 349 ++++++++++++++++ telemetry_server/migrations/001_init.sql | 85 ++++ .../migrations/001_init.sqlite.sql | 59 +++ telemetry_server/store.py | 379 ++++++++++++++++++ 8 files changed, 1036 insertions(+) create mode 100644 telemetry_server/__init__.py create mode 100644 telemetry_server/app.py create mode 100644 telemetry_server/config.py create mode 100644 telemetry_server/ingest.py create mode 100644 telemetry_server/migrations/001_init.sql create mode 100644 telemetry_server/migrations/001_init.sqlite.sql create mode 100644 telemetry_server/store.py diff --git a/pyproject.toml b/pyproject.toml index 70d7398c..5c90f957 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-asyncio", "black", "isort", "pre-commit"] +# The telemetry collector (FEAT-024, telemetry_server/). Kept out of a normal +# install: a bot that only *emits* telemetry has no use for a database driver. +telemetry-server = ["asyncpg"] [tool.isort] profile = "black" diff --git a/telemetry_server/__init__.py b/telemetry_server/__init__.py new file mode 100644 index 00000000..0d8e4ace --- /dev/null +++ b/telemetry_server/__init__.py @@ -0,0 +1,22 @@ +"""The receiving end of Condor's usage telemetry (FEAT-024). + +``condor/telemetry/`` (FEAT-023) makes every consenting install emit a batched, +anonymous event stream. This package is what it reports to: a small FastAPI +service that accepts those envelopes, stores them idempotently, rolls them up, +and leaves five SQL files behind that answer the questions the exercise exists +for. + +It lives in this repository on purpose. The alternative — a separate collector +repo — forces the event taxonomy to exist in two places, and a taxonomy that +drifts between emitter and validator is the classic failure of this kind of +system: the client starts sending a property, the server silently drops it, and +nobody notices for three months. :mod:`telemetry_server.ingest` imports +:mod:`condor.telemetry.schema` **directly**, so drift is not unlikely, it is +impossible. + +Nothing here is installed by default. ``asyncpg`` sits behind the +``telemetry-server`` extra in ``pyproject.toml`` and is imported lazily, so a +normal Condor install carries this directory as dead weight and nothing else. +""" + +__all__ = ["__doc__"] diff --git a/telemetry_server/app.py b/telemetry_server/app.py new file mode 100644 index 00000000..39fa2a0b --- /dev/null +++ b/telemetry_server/app.py @@ -0,0 +1,90 @@ +"""The collector service: two endpoints and a background rollup. + +``create_app()`` mirrors ``condor/web/app.py`` — a factory returning a +configured :class:`~fastapi.FastAPI`, so the test suite builds an app against a +throwaway database instead of importing a module-level singleton that has +already opened a connection to whatever the environment pointed at. + +There is deliberately no read endpoint. Nothing here serves data back to an +install, so the only thing this process will do for an anonymous caller is +accept an envelope and say how much of it it kept. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +from telemetry_server import config, ingest, rollup +from telemetry_server.store import Store, open_store + +log = logging.getLogger(__name__) + + +async def _rollup_loop(app: FastAPI) -> None: + """Recompute the permanent tables on a fixed interval. + + A plain asyncio task rather than a scheduler dependency: the job is "run + this every few hours, never concurrently with itself", which is a loop, and + one service with no extra runtime is easier to reason about than one with a + scheduler in it. + """ + interval = config.rollup_interval_s() + while True: + try: + await asyncio.sleep(interval) + await rollup.run(app.state.store) + except asyncio.CancelledError: + raise + except Exception: + log.exception("Telemetry rollup failed; will retry next interval") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if getattr(app.state, "store", None) is None: + app.state.store = await open_store(config.dsn()) + app.state.owns_store = True + task = asyncio.create_task(_rollup_loop(app)) + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + if getattr(app.state, "owns_store", False): + await app.state.store.close() + + +def create_app(store: Store | None = None) -> FastAPI: + """Build the collector. Pass a ``store`` to drive it against a temp database.""" + app = FastAPI( + title="Condor Telemetry Collector", + version="1.0.0", + lifespan=lifespan, + # The ingest contract is documented in this repository; a public schema + # browser on an unauthenticated endpoint is surface with no reader. + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + app.state.store = store + app.state.owns_store = False + app.include_router(ingest.router) + + @app.get("/health") + async def health() -> JSONResponse: + """Liveness plus a real database round trip.""" + try: + await app.state.store.ping() + except Exception: + log.exception("Telemetry collector health check failed") + return JSONResponse(status_code=503, content={"status": "degraded"}) + return JSONResponse(status_code=200, content={"status": "ok"}) + + return app diff --git a/telemetry_server/config.py b/telemetry_server/config.py new file mode 100644 index 00000000..baa8c212 --- /dev/null +++ b/telemetry_server/config.py @@ -0,0 +1,49 @@ +"""Every knob the collector reads from its environment, in one place. + +The house pattern (``utils/config.py``) is a module of module-level reads rather +than env lookups scattered through the code, so that what a deployment can +change is one file long. These are read per call rather than at import, because +the test suite sets them with ``monkeypatch.setenv``. +""" + +from __future__ import annotations + +import os + +DEFAULT_DSN = "sqlite:///telemetry.db" +DEFAULT_RETENTION_DAYS = 90 +DEFAULT_ROLLUP_INTERVAL_S = 6 * 3600 + + +def dsn() -> str: + """Where events go. A Postgres URL in production, a SQLite path otherwise.""" + return os.environ.get("TELEMETRY_DSN", "").strip() or DEFAULT_DSN + + +def trust_proxy() -> bool: + """Whether ``X-Forwarded-For`` may be believed. + + Off unless an operator says otherwise, because the header is written by the + caller: trusting it on a directly-exposed service hands every client a + one-header rate-limit bypass. + """ + return os.environ.get("TELEMETRY_TRUSTED_PROXY", "").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def retention_days() -> int: + """How long raw events live. Rollups are permanent regardless.""" + try: + return max(1, int(os.environ.get("TELEMETRY_RETENTION_DAYS", "").strip())) + except ValueError: + return DEFAULT_RETENTION_DAYS + + +def rollup_interval_s() -> int: + try: + return max(60, int(os.environ.get("TELEMETRY_ROLLUP_INTERVAL_S", "").strip())) + except ValueError: + return DEFAULT_ROLLUP_INTERVAL_S diff --git a/telemetry_server/ingest.py b/telemetry_server/ingest.py new file mode 100644 index 00000000..46937865 --- /dev/null +++ b/telemetry_server/ingest.py @@ -0,0 +1,349 @@ +"""``POST /v1/events`` — the one public, unauthenticated door. + +Installs are anonymous by design, so there is no credential to check: anything +on the internet can POST here. The pipeline below is ordered so that everything +cheap happens before anything expensive, and so that a hostile body is refused +before it can cost a parse, a memory allocation, or a database round trip. + +1. **Body cap.** 1 MB, enforced while streaming. An oversized body is dropped + without ever being fully buffered or parsed. +2. **Rate limit by source IP.** Token bucket, before the JSON parser runs. +3. **Parse and validate the envelope.** Unknown ``schema`` versions and more + than :data:`MAX_EVENTS` events are refused whole. +4. **Rate limit by ``install_id``**, now that we know it — still before any + database contact. +5. **Validate each event** against :mod:`condor.telemetry.schema`, the same + module the client sanitises with. Offenders are dropped and counted; the + rest of the batch is still accepted, because rejecting a whole envelope over + one malformed event would let a single client bug erase a day of otherwise + good data. +6. **Persist** in one transaction, with ``ON CONFLICT DO NOTHING``. + +Two rules hold everywhere in this module. Nothing from a payload is ever +formatted into SQL — :mod:`telemetry_server.store` takes parameters only. And +no error response ever echoes any part of the request: the replies are fixed +strings, so this endpoint cannot be turned into a reflector or used to confirm +what the server did with a probe. +""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from collections import OrderedDict +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from condor.telemetry import schema + +log = logging.getLogger(__name__) + +router = APIRouter() + +KNOWN_SCHEMAS = frozenset({1}) +LEVELS = frozenset({"ping", "usage"}) + +MAX_BODY_BYTES = 1024 * 1024 +MAX_EVENTS = 500 +MAX_PROPS = 64 +MAX_PROVIDERS = 10 +MAX_COUNT = 1_000_000 +MAX_DROPPED = 10_000_000 +# Client clocks on self-hosted boxes drift and occasionally lie. Anything +# further out than this is the clock, not the event. +CLOCK_SLACK = timedelta(hours=48) + +RATE_PER_HOUR = 60 + + +class RateLimiter: + """Token bucket per key, with a bounded key space. + + The bound matters as much as the rate. ``install_id`` is attacker-chosen, so + a limiter that kept one bucket per id it has ever seen would be a memory + exhaustion primitive wearing a safety hat. Keys are held in an LRU capped at + :data:`max_keys`; evicting the least recently used one costs an attacker a + fresh bucket, which is exactly what they would have had anyway. + """ + + def __init__(self, per_hour: int = RATE_PER_HOUR, max_keys: int = 20_000) -> None: + self.per_hour = float(per_hour) + self.max_keys = max_keys + self._buckets: OrderedDict[str, tuple[float, float]] = OrderedDict() + + def allow(self, key: str) -> bool: + now = time.monotonic() + tokens, last = self._buckets.pop(key, (self.per_hour, now)) + tokens = min(self.per_hour, tokens + (now - last) * self.per_hour / 3600.0) + allowed = tokens >= 1.0 + self._buckets[key] = (tokens - 1.0 if allowed else tokens, now) + while len(self._buckets) > self.max_keys: + self._buckets.popitem(last=False) + return allowed + + def reset(self) -> None: + self._buckets.clear() + + +by_ip = RateLimiter() +by_install = RateLimiter() + + +class Refused(Exception): + """A whole envelope is unusable. Carries a fixed, contentless reason.""" + + def __init__(self, status: int, reason: str) -> None: + super().__init__(reason) + self.status = status + self.reason = reason + + +def _refuse(status: int, reason: str) -> JSONResponse: + """Every failure reply is a constant. No request data crosses back out.""" + return JSONResponse(status_code=status, content={"error": reason}) + + +async def read_body(request: Request) -> bytes: + """Read at most :data:`MAX_BODY_BYTES`, refusing anything larger. + + The declared ``Content-Length`` is checked first because it is free, but it + is not trusted: a chunked request can omit or understate it, so the stream + is counted as it arrives and abandoned the moment it crosses the cap. + """ + declared = request.headers.get("content-length") + if declared is not None: + try: + if int(declared) > MAX_BODY_BYTES: + raise Refused(413, "body_too_large") + except ValueError: + raise Refused(400, "bad_request") from None + + chunks: list[bytes] = [] + size = 0 + async for chunk in request.stream(): + size += len(chunk) + if size > MAX_BODY_BYTES: + raise Refused(413, "body_too_large") + chunks.append(chunk) + return b"".join(chunks) + + +def client_ip(request: Request) -> str: + """The peer address, or the forwarded one only where a proxy is trusted. + + ``X-Forwarded-For`` is a client-supplied header. Honouring it by default + would hand every caller a free rate-limit bypass, so it is read only when + the operator has said there is a proxy in front (``TELEMETRY_TRUSTED_PROXY``). + """ + from telemetry_server.config import trust_proxy + + if trust_proxy(): + forwarded = request.headers.get("x-forwarded-for", "") + first = forwarded.split(",")[0].strip() + if first: + return first[:64] + return request.client.host if request.client else "unknown" + + +def _uuid_or_none(value: object) -> str | None: + if not isinstance(value, str) or len(value) > 45: + return None + try: + return str(uuid.UUID(value)) + except ValueError: + return None + + +def _ts_or_none(value: object) -> datetime | None: + if not isinstance(value, str) or len(value) > 40: + return None + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def clamp_ts(ts: datetime, received_at: datetime) -> datetime: + """Pull a lying clock back inside the window we are willing to believe.""" + return max(received_at - CLOCK_SLACK, min(ts, received_at + CLOCK_SLACK)) + + +def _flag(value: object) -> bool | None: + return value if isinstance(value, bool) else None + + +def _count(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return max(0, min(MAX_COUNT, value)) + + +def _install_row(envelope: dict, install_id: str, received_at: datetime) -> dict: + """Flatten ``app`` and ``config`` into the columns we keep. + + Only declared keys are read. An unknown field in either sub-object is not + stored, not logged, and not reflected — it simply does not exist here. + """ + app = envelope.get("app") + app = app if isinstance(app, dict) else {} + config = envelope.get("config") + config = config if isinstance(config, dict) else {} + + providers = config.get("llm_providers") + if isinstance(providers, (list, tuple)): + clean = [schema.clean_str(p) for p in list(providers)[:MAX_PROVIDERS]] + providers = [p for p in clean if p] + else: + providers = [] + + return { + "install_id": install_id, + "received_at": received_at, + "level": envelope["level"], + "version": schema.clean_str(app.get("version")), + "branch": schema.clean_str(app.get("branch")), + "os": schema.clean_str(app.get("os")), + "arch": schema.clean_str(app.get("arch")), + "python": schema.clean_str(app.get("python")), + "in_docker": _flag(app.get("in_docker")), + "has_hb_api": _flag(config.get("has_hb_api")), + "has_gateway": _flag(config.get("has_gateway")), + "has_web": _flag(config.get("has_web")), + "user_count": _count(config.get("user_count")), + "server_count": _count(config.get("server_count")), + "agent_count": _count(config.get("agent_count")), + "llm_providers": providers, + } + + +def validate(envelope: object, received_at: datetime) -> tuple[dict, list[dict], int]: + """Turn an untrusted body into rows, or raise :class:`Refused`. + + Returns ``(install, events, rejected)``. ``rejected`` counts every offender + dropped — a whole event whose name we do not know, and each individual + property that fell outside its declared spec. Both are the early warning + that a taxonomy change broke something, so they are counted together and + reported back rather than swallowed. + """ + if not isinstance(envelope, dict): + raise Refused(400, "invalid_envelope") + if envelope.get("schema") not in KNOWN_SCHEMAS: + raise Refused(400, "unknown_schema") + + install_id = _uuid_or_none(envelope.get("install_id")) + if install_id is None: + raise Refused(400, "invalid_envelope") + if envelope.get("level") not in LEVELS: + raise Refused(400, "invalid_envelope") + + raw_events = envelope.get("events") + if not isinstance(raw_events, list): + raise Refused(400, "invalid_envelope") + if len(raw_events) > MAX_EVENTS: + raise Refused(413, "too_many_events") + + install = _install_row(envelope, install_id, received_at) + + events: list[dict] = [] + rejected = 0 + seen: set[str] = set() + for raw in raw_events: + if not isinstance(raw, dict): + rejected += 1 + continue + event_id = _uuid_or_none(raw.get("id")) + ts = _ts_or_none(raw.get("ts")) + name = raw.get("name") + props = raw.get("props") + if props is None: + props = {} + if ( + event_id is None + or ts is None + or not isinstance(name, str) + or not schema.is_known(name) + or not isinstance(props, dict) + or len(props) > MAX_PROPS + ): + rejected += 1 + continue + if event_id in seen: + # A duplicate inside one envelope: the database would swallow it, + # but counting it here keeps `accepted` equal to rows written. + rejected += 1 + continue + seen.add(event_id) + + clean = schema.sanitize(name, props) + if clean is None: + rejected += 1 + continue + rejected += len(props) - len(clean) + + events.append( + { + "id": event_id, + "install_id": install_id, + "ts": clamp_ts(ts, received_at), + "received_at": received_at, + "name": name, + "props": clean, + } + ) + + return install, events, rejected + + +def _dropped(envelope: dict) -> int: + value = envelope.get("dropped") + if isinstance(value, bool) or not isinstance(value, int): + return 0 + return max(0, min(MAX_DROPPED, value)) + + +@router.post("/v1/events") +async def ingest(request: Request) -> JSONResponse: + try: + body = await read_body(request) + except Refused as refusal: + return _refuse(refusal.status, refusal.reason) + + if not by_ip.allow(client_ip(request)): + return _refuse(429, "rate_limited") + + try: + envelope = json.loads(body) + except ValueError: + return _refuse(400, "invalid_json") + + try: + install, events, rejected = validate(envelope, _now()) + except Refused as refusal: + return _refuse(refusal.status, refusal.reason) + + if not by_install.allow(install["install_id"]): + return _refuse(429, "rate_limited") + + store = request.app.state.store + try: + stored, duplicates = await store.record(install, events, _dropped(envelope)) + except Exception: + # The reason a write failed is ours to read, never the caller's. + log.exception("Telemetry ingest could not persist an envelope") + return _refuse(503, "unavailable") + + return JSONResponse( + status_code=202, + content={"accepted": stored, "rejected": rejected, "duplicates": duplicates}, + ) + + +def _now() -> datetime: + return datetime.now(timezone.utc) diff --git a/telemetry_server/migrations/001_init.sql b/telemetry_server/migrations/001_init.sql new file mode 100644 index 00000000..6a00d091 --- /dev/null +++ b/telemetry_server/migrations/001_init.sql @@ -0,0 +1,85 @@ +-- Collector schema (FEAT-024), Postgres flavour. Idempotent: safe to re-run on +-- every boot, which is how telemetry_server.app applies it. +-- +-- `installs` is upserted on every envelope; `events` is append-only and +-- partitioned by month so the 90-day retention is a DROP TABLE rather than a +-- mass DELETE; `install_days` is written on the ingest path because it is what +-- survives raw expiry and every retention question depends on it; +-- `daily_metrics` is the nightly rollup and is permanent. + +CREATE TABLE IF NOT EXISTS installs ( + install_id uuid PRIMARY KEY, + first_seen timestamptz NOT NULL, + last_seen timestamptz NOT NULL, + level text NOT NULL, + version text, + branch text, + os text, + arch text, + python text, + in_docker boolean, + -- Capability flags the client actually sends (condor/telemetry/context.py + -- `config_shape`). The original design omitted them; the wire has them. + has_hb_api boolean, + has_gateway boolean, + has_web boolean, + user_count integer, + server_count integer, + agent_count integer, + llm_providers text[], + dropped_total bigint NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS events ( + id uuid NOT NULL, + install_id uuid NOT NULL, + -- Both timestamps are kept on purpose. Client clocks on self-hosted boxes + -- drift and occasionally lie; ingest clamps `ts` to received_at +/- 48h, + -- dashboards count volume by `received_at`, and `ts` is only trusted for + -- ordering inside one install's session. + ts timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + name text NOT NULL, + props jsonb NOT NULL DEFAULT '{}', + PRIMARY KEY (id, ts) +) PARTITION BY RANGE (ts); + +CREATE INDEX IF NOT EXISTS events_name_ts ON events (name, ts); +CREATE INDEX IF NOT EXISTS events_install_ts ON events (install_id, ts); + +CREATE TABLE IF NOT EXISTS install_days ( + install_id uuid NOT NULL, + day date NOT NULL, + events bigint NOT NULL DEFAULT 0, + errors bigint NOT NULL DEFAULT 0, + version text, + PRIMARY KEY (install_id, day) +); + +CREATE TABLE IF NOT EXISTS daily_metrics ( + day date NOT NULL, + metric text NOT NULL, + dim text NOT NULL DEFAULT '', + value numeric NOT NULL, + PRIMARY KEY (day, metric, dim) +); + +-- A partitioned table with no partition rejects every insert, so seed this +-- month and next. telemetry_server.rollup keeps running a month ahead and drops +-- what has aged out. +DO $$ +DECLARE + start_month date := date_trunc('month', now())::date; + bound date; +BEGIN + FOR i IN 0..1 LOOP + bound := (start_month + (i || ' month')::interval)::date; + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF events ' + 'FOR VALUES FROM (%L) TO (%L)', + 'events_' || to_char(bound, 'YYYY_MM'), + bound, + (bound + interval '1 month')::date + ); + END LOOP; +END $$; diff --git a/telemetry_server/migrations/001_init.sqlite.sql b/telemetry_server/migrations/001_init.sqlite.sql new file mode 100644 index 00000000..39d72e42 --- /dev/null +++ b/telemetry_server/migrations/001_init.sqlite.sql @@ -0,0 +1,59 @@ +-- The same schema in SQLite, for the test suite and a single-box trial. +-- +-- Three differences, all forced by the engine and none of them semantic: +-- there is no partitioning (retention is a DELETE, which is fine at test +-- volume), no array type (`llm_providers` is a JSON array in TEXT), and no +-- native date/timestamp type (everything is ISO-8601 UTC text, which sorts and +-- compares correctly). Column names, keys and conflict targets are identical to +-- 001_init.sql so telemetry_server.store can share one set of statements. + +CREATE TABLE IF NOT EXISTS installs ( + install_id TEXT PRIMARY KEY, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + level TEXT NOT NULL, + version TEXT, + branch TEXT, + os TEXT, + arch TEXT, + python TEXT, + in_docker INTEGER, + has_hb_api INTEGER, + has_gateway INTEGER, + has_web INTEGER, + user_count INTEGER, + server_count INTEGER, + agent_count INTEGER, + llm_providers TEXT, + dropped_total INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS events ( + id TEXT NOT NULL, + install_id TEXT NOT NULL, + ts TEXT NOT NULL, + received_at TEXT NOT NULL, + name TEXT NOT NULL, + props TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (id, ts) +); + +CREATE INDEX IF NOT EXISTS events_name_ts ON events (name, ts); +CREATE INDEX IF NOT EXISTS events_install_ts ON events (install_id, ts); + +CREATE TABLE IF NOT EXISTS install_days ( + install_id TEXT NOT NULL, + day TEXT NOT NULL, + events INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + version TEXT, + PRIMARY KEY (install_id, day) +); + +CREATE TABLE IF NOT EXISTS daily_metrics ( + day TEXT NOT NULL, + metric TEXT NOT NULL, + dim TEXT NOT NULL DEFAULT '', + value REAL NOT NULL, + PRIMARY KEY (day, metric, dim) +); diff --git a/telemetry_server/store.py b/telemetry_server/store.py new file mode 100644 index 00000000..5efd8b2c --- /dev/null +++ b/telemetry_server/store.py @@ -0,0 +1,379 @@ +"""Persistence for the collector — one interface, two backends. + +Production is Postgres (FEAT-024 chose it: known stack, ``ON CONFLICT`` gives +idempotency in one line, monthly partitions make retention a ``DROP TABLE``). +But a collector whose tests need a database daemon is a collector whose tests do +not run, so the same interface also speaks SQLite, which is what the suite in +``tests/test_telemetry_server.py`` drives. The two backends share their SQL: +every statement is written once with ``$n`` placeholders and a ``{jsonb}`` cast +hole, and each backend adapts only those two things. Sharing the statements is +the point — a second copy would drift exactly the way a second copy of the event +taxonomy would. + +Everything an envelope contributes is parameterised. No value from a payload is +ever formatted into a statement; the only string interpolation in this file is +the partition maintenance in :mod:`telemetry_server.rollup`, which builds names +from dates it computed itself. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +from datetime import date, datetime, timezone +from pathlib import Path + +MIGRATIONS = Path(__file__).parent / "migrations" + +_PLACEHOLDER = re.compile(r"\$(\d+)") + + +# ── The shared statements ──────────────────────────────────────────────── +# `$2` appears twice in the install upsert (first_seen and last_seen start +# equal); the SQLite adapter re-orders arguments by order of appearance, so a +# repeated placeholder is safe in both dialects. + +UPSERT_INSTALL = """ +INSERT INTO installs ( + install_id, first_seen, last_seen, level, version, branch, + os, arch, python, in_docker, has_hb_api, has_gateway, has_web, + user_count, server_count, agent_count, llm_providers, dropped_total) +VALUES ($1, $2, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17) +ON CONFLICT (install_id) DO UPDATE SET + last_seen = excluded.last_seen, + level = excluded.level, + version = excluded.version, + branch = excluded.branch, + os = excluded.os, + arch = excluded.arch, + python = excluded.python, + in_docker = excluded.in_docker, + has_hb_api = excluded.has_hb_api, + has_gateway = excluded.has_gateway, + has_web = excluded.has_web, + user_count = excluded.user_count, + server_count = excluded.server_count, + agent_count = excluded.agent_count, + llm_providers = excluded.llm_providers, + dropped_total = installs.dropped_total + excluded.dropped_total +""" +# `first_seen` is deliberately absent from the DO UPDATE list: first contact is +# a fact about the past and a later envelope has no business moving it. + +INSERT_EVENT = """ +INSERT INTO events (id, install_id, ts, received_at, name, props) +VALUES ($1, $2, $3, $4, $5, $6{jsonb}) +ON CONFLICT DO NOTHING +""" + +UPSERT_INSTALL_DAY = """ +INSERT INTO install_days (install_id, day, events, errors, version) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (install_id, day) DO UPDATE SET + events = install_days.events + excluded.events, + errors = install_days.errors + excluded.errors, + version = excluded.version +""" + +UPSERT_METRIC = """ +INSERT INTO daily_metrics (day, metric, dim, value) +VALUES ($1, $2, $3, $4) +ON CONFLICT (day, metric, dim) DO UPDATE SET value = excluded.value +""" + + +def _to_qmark(sql: str, args: tuple) -> tuple[str, list]: + """Rewrite ``$n`` placeholders as ``?`` and re-order args to match. + + Re-ordering by order of appearance is what makes a repeated ``$n`` legal in + the shared statements above. + """ + ordered: list = [] + + def swap(match: re.Match) -> str: + ordered.append(args[int(match.group(1)) - 1]) + return "?" + + return _PLACEHOLDER.sub(swap, sql), ordered + + +class Store: + """What ingest and rollup are allowed to ask of a database. + + Two SQL fragments differ between the dialects and cannot be parameterised — + casting a timestamp to a day, and reading one key out of a JSON column. + Subclasses expose them as :attr:`DAY` / :meth:`json_get` and + :mod:`telemetry_server.rollup` formats them into its aggregates. Every value + that reaches those aggregates is still a bound parameter; what is + interpolated is a constant chosen in this file and a key name chosen in + ``rollup.py``, never anything an envelope carried. + """ + + #: SQL expression turning ``events.received_at`` into a day. + DAY = "received_at::date" + + def json_get(self, column: str, key: str) -> str: + """SQL reading one top-level key of a JSON column as text.""" + return f"{column}->>'{key}'" + + def json_num(self, column: str, key: str) -> str: + """SQL reading one top-level key of a JSON column as a number.""" + return f"({column}->>'{key}')::numeric" + + async def migrate(self) -> None: + raise NotImplementedError + + async def ping(self) -> bool: + raise NotImplementedError + + async def close(self) -> None: + raise NotImplementedError + + async def record( + self, install: dict, events: list[dict], dropped: int + ) -> tuple[int, int]: + """Persist one validated envelope in a single transaction. + + Returns ``(stored, duplicates)``. ``install_days`` is incremented from + *newly inserted* rows only, so the client's retry-after-timeout path + cannot inflate a metric even though it legitimately re-sends the batch. + """ + raise NotImplementedError + + async def fetch(self, sql: str, *args) -> list[tuple]: + raise NotImplementedError + + async def execute(self, sql: str, *args) -> None: + raise NotImplementedError + + async def put_metric(self, day: date, metric: str, dim: str, value: float) -> None: + await self.execute(UPSERT_METRIC, day, metric, dim, float(value)) + + +def _day_buckets(events: list[dict]) -> dict: + """Group inserted events into ``(day) -> (count, errors)``.""" + buckets: dict[date, list[int]] = {} + for event in events: + day = event["ts"].astimezone(timezone.utc).date() + slot = buckets.setdefault(day, [0, 0]) + slot[0] += 1 + if event["name"] in ("error", "upstream_error"): + slot[1] += 1 + return buckets + + +class SqliteStore(Store): + """The test and small-deployment backend. + + ``sqlite3`` is synchronous, so every call holds a :class:`threading.Lock` + for the microseconds it takes — a thread lock rather than an asyncio one + precisely because there is no ``await`` inside the critical section, and + because an :class:`asyncio.Lock` binds itself to the first event loop that + touches it, which a store built in one loop and served from another would + trip over immediately. + """ + + DAY = "substr(received_at, 1, 10)" + + def json_get(self, column: str, key: str) -> str: + return f"json_extract({column}, '$.{key}')" + + def json_num(self, column: str, key: str) -> str: + # json_extract already yields SQLite's own numeric affinity for a JSON + # number, so unlike Postgres there is nothing to cast. + return f"json_extract({column}, '$.{key}')" + + def __init__(self, path: str) -> None: + self._path = path + self._lock = threading.Lock() + self._conn = sqlite3.connect(path, check_same_thread=False) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + + def _run(self, sql: str, args: tuple) -> sqlite3.Cursor: + # Only the shared INSERT carries the cast hole; leave every other + # statement's text alone so a stray brace in a rollup aggregate can + # never be mistaken for a format field. + if "{jsonb}" in sql: + sql = sql.format(jsonb="") + statement, ordered = _to_qmark(sql, _as_text_dates(args)) + return self._conn.execute(statement, ordered) + + async def migrate(self) -> None: + with self._lock: + self._conn.executescript( + (MIGRATIONS / "001_init.sqlite.sql").read_text(encoding="utf-8") + ) + self._conn.commit() + + async def ping(self) -> bool: + with self._lock: + self._conn.execute("SELECT 1").fetchone() + return True + + async def close(self) -> None: + with self._lock: + self._conn.close() + + async def record( + self, install: dict, events: list[dict], dropped: int + ) -> tuple[int, int]: + with self._lock: + try: + self._run(UPSERT_INSTALL, _install_args(install, dropped, json.dumps)) + inserted: list[dict] = [] + for event in events: + cursor = self._run( + INSERT_EVENT, _event_args(event, str, json.dumps) + ) + if cursor.rowcount: + inserted.append(event) + for day, (count, errors) in _day_buckets(inserted).items(): + self._run( + UPSERT_INSTALL_DAY, + ( + install["install_id"], + day.isoformat(), + count, + errors, + install["version"], + ), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + return len(inserted), len(events) - len(inserted) + + async def fetch(self, sql: str, *args) -> list[tuple]: + with self._lock: + return list(self._run(sql, args).fetchall()) + + async def execute(self, sql: str, *args) -> None: + with self._lock: + self._run(sql, args) + self._conn.commit() + + +def _as_text_dates(args: tuple) -> tuple: + """SQLite has no date or timestamp type, and its implicit datetime adapter is + deprecated. Dates and timestamps go in as ISO-8601 text, matching the DDL and + sorting correctly.""" + return tuple(a.isoformat() if isinstance(a, (date, datetime)) else a for a in args) + + +class PostgresStore(Store): + """The production backend. ``asyncpg`` is imported here and nowhere else.""" + + def __init__(self, dsn: str) -> None: + self._dsn = dsn + self._pool = None + + async def connect(self) -> None: + import asyncpg # noqa: PLC0415 - behind the telemetry-server extra + + self._pool = await asyncpg.create_pool(self._dsn, min_size=1, max_size=8) + + async def migrate(self) -> None: + sql = (MIGRATIONS / "001_init.sql").read_text(encoding="utf-8") + async with self._pool.acquire() as conn: + await conn.execute(sql) + + async def ping(self) -> bool: + async with self._pool.acquire() as conn: + await conn.fetchval("SELECT 1") + return True + + async def close(self) -> None: + if self._pool is not None: + await self._pool.close() + + async def record( + self, install: dict, events: list[dict], dropped: int + ) -> tuple[int, int]: + import uuid as _uuid + + statement = INSERT_EVENT.format(jsonb="::jsonb") + async with self._pool.acquire() as conn: + async with conn.transaction(): + await conn.execute( + UPSERT_INSTALL, *_install_args(install, dropped, list) + ) + inserted = [] + for event in events: + status = await conn.execute( + statement, *_event_args(event, _uuid.UUID, json.dumps) + ) + # asyncpg returns "INSERT 0 1", or "INSERT 0 0" when the + # ON CONFLICT swallowed a retry. + if status.rsplit(" ", 1)[-1] != "0": + inserted.append(event) + for day, (count, errors) in _day_buckets(inserted).items(): + await conn.execute( + UPSERT_INSTALL_DAY, + _uuid.UUID(install["install_id"]), + day, + count, + errors, + install["version"], + ) + return len(inserted), len(events) - len(inserted) + + async def fetch(self, sql: str, *args) -> list[tuple]: + async with self._pool.acquire() as conn: + return [tuple(r) for r in await conn.fetch(sql, *args)] + + async def execute(self, sql: str, *args) -> None: + async with self._pool.acquire() as conn: + await conn.execute(sql, *args) + + +def _install_args(install: dict, dropped: int, providers) -> tuple: + return ( + install["install_id"], + install["received_at"], + install["level"], + install["version"], + install["branch"], + install["os"], + install["arch"], + install["python"], + install["in_docker"], + install["has_hb_api"], + install["has_gateway"], + install["has_web"], + install["user_count"], + install["server_count"], + install["agent_count"], + providers(install["llm_providers"]), + dropped, + ) + + +def _event_args(event: dict, ident, dump) -> tuple: + return ( + ident(event["id"]), + ident(event["install_id"]), + event["ts"], + event["received_at"], + event["name"], + dump(event["props"]), + ) + + +async def open_store(dsn: str) -> Store: + """Build the backend the DSN asks for, migrated and ready. + + Anything that is not a Postgres URL is a SQLite path, which is how the tests + and a single-box trial run get a collector without installing a daemon. + """ + if dsn.startswith(("postgres://", "postgresql://")): + store: Store = PostgresStore(dsn) + await store.connect() + else: + store = SqliteStore(dsn.removeprefix("sqlite:///").removeprefix("sqlite://")) + await store.migrate() + return store From b3550976d3eb96a7c1f10d9d828cbabe11fb26c9 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 00:00:51 +0300 Subject: [PATCH 017/116] Answer the five questions from tables that outlive the raw events (FEAT-024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retention and DAU computed over raw events mean a full scan per dashboard panel, and raw events expire at 90 days anyway. So every question is answered from daily_metrics, which the rollup writes and which is never deleted. This is the one piece of "premature" structure worth paying for from day one: backfilling a rollup after dropping the raw data it came from is impossible. The aggregation is split deliberately. SQL does the grouping — cheap, indexed, and identical in both dialects. Python does the date arithmetic and the cohort matching, which is exactly where Postgres and SQLite diverge most. The result is one rollup that runs unchanged against either, so the numbers the tests check are the numbers production computes rather than a parallel implementation. Cohorts come from MIN(day) in install_days rather than installs.first_seen, so retention keeps working for an install whose row was upserted long after its first event. dropped_total and dropped_rate are charted next to volume on purpose. The client's emitter clips a crash loop to 60 events/min and confesses the count in the envelope; without surfacing it, client-side rate limiting would masquerade as "things got quieter" during precisely the incident we most want to see. queries/ is the real deliverable — five plain-ANSI files, each runnable standalone against Postgres or the SQLite the tests seed, each readable without Grafana. Building a UI for five questions is a product; five panels over SQL is an afternoon. No query groups by user_hash. It is salted per install, so it counts distinct users inside one install but is meaningless across them, and grouping by it globally would invent the cross-install identity the client deliberately refused to provide. Partition maintenance runs a month ahead and drops what has aged out, which is an instant DROP TABLE rather than a mass DELETE. It is the one Postgres-only part; SQLite falls back to a DELETE, which is the right answer at the volume SQLite is used for. The table names it builds come from dates it computed itself — nothing from a payload reaches a formatted statement. --- telemetry_server/queries/adoption.sql | 19 ++ telemetry_server/queries/agent_economics.sql | 27 ++ telemetry_server/queries/feature_usage.sql | 25 ++ telemetry_server/queries/reliability.sql | 30 ++ telemetry_server/queries/retention.sql | 21 ++ telemetry_server/rollup.py | 339 +++++++++++++++++++ 6 files changed, 461 insertions(+) create mode 100644 telemetry_server/queries/adoption.sql create mode 100644 telemetry_server/queries/agent_economics.sql create mode 100644 telemetry_server/queries/feature_usage.sql create mode 100644 telemetry_server/queries/reliability.sql create mode 100644 telemetry_server/queries/retention.sql create mode 100644 telemetry_server/rollup.py diff --git a/telemetry_server/queries/adoption.sql b/telemetry_server/queries/adoption.sql new file mode 100644 index 00000000..d1fa5227 --- /dev/null +++ b/telemetry_server/queries/adoption.sql @@ -0,0 +1,19 @@ +-- Q1: How many installs are alive, on what, and how fast do upgrades spread? +-- +-- Reads `daily_metrics` only, so it stays correct after raw events expire and +-- costs a few thousand rows rather than a full scan. Every query in this +-- directory is plain ANSI: it runs against the collector's Postgres and against +-- the SQLite the test suite seeds, and it is readable without Grafana. + +SELECT day, metric, dim, value +FROM daily_metrics +WHERE metric IN ('dau', 'wau', 'mau') +ORDER BY day, metric; + +-- Version and OS mix, snapshotted once per rollup. The series of snapshots is +-- what answers "how long does an upgrade take to propagate" — a version's share +-- decaying across consecutive days is the propagation curve. +SELECT day, metric, dim AS value_of, value +FROM daily_metrics +WHERE metric IN ('version_share', 'os_share', 'provider_share') +ORDER BY day, metric, value DESC; diff --git a/telemetry_server/queries/agent_economics.sql b/telemetry_server/queries/agent_economics.sql new file mode 100644 index 00000000..dbea2432 --- /dev/null +++ b/telemetry_server/queries/agent_economics.sql @@ -0,0 +1,27 @@ +-- Q5: How are agents actually used, and what does a turn cost? + +SELECT day, metric, dim AS value_of, value +FROM daily_metrics +WHERE metric IN ('agent_provider', 'model_rank', 'agent_kind') +ORDER BY metric, value DESC; + +-- Outcome mix. A rising `aborted` share is a usability signal; a rising `error` +-- share is a reliability one. +SELECT dim AS outcome, value AS turns +FROM daily_metrics +WHERE metric = 'agent_outcome' +ORDER BY value DESC; + +-- What a turn costs, and what a routine costs. +SELECT day, metric, value +FROM daily_metrics +WHERE metric IN ('agent_tool_calls_avg', 'agent_duration_ms_avg', + 'routine_duration_ms_avg') +ORDER BY day, metric; + +-- Dry run versus live, and how often a confirmation is actually granted. A deny +-- or timeout rate that climbs means the agent is asking for the wrong things. +SELECT metric, dim AS value_of, value +FROM daily_metrics +WHERE metric IN ('strategy_mode', 'confirmation_decision') +ORDER BY metric, value DESC; diff --git a/telemetry_server/queries/feature_usage.sql b/telemetry_server/queries/feature_usage.sql new file mode 100644 index 00000000..48b99c47 --- /dev/null +++ b/telemetry_server/queries/feature_usage.sql @@ -0,0 +1,25 @@ +-- Q3: What is actually used, and what are we maintaining for nobody? +-- +-- Note what is absent: nothing groups by `user_hash`. It is salted per install +-- (FEAT-023), so it can be counted inside one install but is meaningless +-- across installs, and a query that grouped by it globally would be inventing +-- a cross-install identity the client deliberately refused to provide. + +SELECT day, dim AS event_name, value AS events +FROM daily_metrics +WHERE metric = 'event_rank' +ORDER BY day, value DESC; + +-- The key dimension behind each surface: which commands, which modules, which +-- routines, which connectors. +SELECT day, metric, dim AS name, value AS uses +FROM daily_metrics +WHERE metric IN ('command_rank', 'action_rank', 'routine_rank', 'trade_rank') +ORDER BY metric, value DESC; + +-- The activation funnel. `feature_first_use` fires once per install, ever, so +-- this counts installs that ever reached a feature, not how often they use it. +SELECT dim AS feature, value AS installs_activated +FROM daily_metrics +WHERE metric = 'activation' +ORDER BY value DESC; diff --git a/telemetry_server/queries/reliability.sql b/telemetry_server/queries/reliability.sql new file mode 100644 index 00000000..b263780d --- /dev/null +++ b/telemetry_server/queries/reliability.sql @@ -0,0 +1,30 @@ +-- Q4: What is broken out there? +-- +-- `error_rate` is errors per event per install-day, so a single install in a +-- crash loop shows up as a bad day rather than drowning the fleet average. + +SELECT day, value AS errors_per_event +FROM daily_metrics +WHERE metric = 'error_rate' +ORDER BY day; + +-- Failure groups, keyed by exception type and the hash of the message. The +-- message itself is never transmitted (FEAT-023) — grouping works off the hash. +SELECT dim AS exc_type_and_sig, value AS occurrences +FROM daily_metrics +WHERE metric = 'error_group' +ORDER BY value DESC; + +-- Which upstream is failing: the Hummingbot API, Gateway, an LLM, or Telegram. +SELECT dim AS service, value AS failures +FROM daily_metrics +WHERE metric = 'upstream_error_rank' +ORDER BY value DESC; + +-- Client-side rate limiting must never look like a quiet week. `dropped` is the +-- emitter's own confession, and charting it next to volume is what keeps a +-- taxonomy change or an error flood visible instead of silent. +SELECT day, metric, value +FROM daily_metrics +WHERE metric IN ('dropped_total', 'dropped_rate') +ORDER BY day; diff --git a/telemetry_server/queries/retention.sql b/telemetry_server/queries/retention.sql new file mode 100644 index 00000000..f1dee5bb --- /dev/null +++ b/telemetry_server/queries/retention.sql @@ -0,0 +1,21 @@ +-- Q2: Do people stay? D1 / D7 / D30 by first-seen cohort. +-- +-- `day` here is the cohort's first-seen day, not the day the metric describes. +-- Cohorts are derived from `install_days`, which is written on the ingest path +-- and never expires, so retention survives the 90-day raw retention window. + +SELECT + cohort.day AS cohort_day, + cohort.value AS installs, + d1.value AS retained_d1, + d7.value AS retained_d7, + d30.value AS retained_d30 +FROM daily_metrics AS cohort +LEFT JOIN daily_metrics AS d1 + ON d1.day = cohort.day AND d1.metric = 'retention_d1' +LEFT JOIN daily_metrics AS d7 + ON d7.day = cohort.day AND d7.metric = 'retention_d7' +LEFT JOIN daily_metrics AS d30 + ON d30.day = cohort.day AND d30.metric = 'retention_d30' +WHERE cohort.metric = 'cohort_size' +ORDER BY cohort.day; diff --git a/telemetry_server/rollup.py b/telemetry_server/rollup.py new file mode 100644 index 00000000..26dcb3f5 --- /dev/null +++ b/telemetry_server/rollup.py @@ -0,0 +1,339 @@ +"""The nightly job: small permanent tables, and partition housekeeping. + +Retention and DAU computed over raw events mean a full scan per dashboard +panel, and raw events expire at :func:`telemetry_server.config.retention_days` +anyway. So every question is answered from ``daily_metrics``, which this module +writes and which is never deleted. Backfilling a rollup after dropping the raw +data it came from is impossible, which is why this exists from day one rather +than "later". + +The aggregation is deliberately split: SQL does the grouping (cheap, indexed, +and identical in both dialects), Python does the date arithmetic and the +cohort matching. Date arithmetic is where the two dialects diverge most, and at +a few hundred installs the rows involved fit in a dict comfortably. The result +is one rollup that runs unchanged against Postgres and SQLite, so the numbers +the tests check are the numbers production computes. + +Partition maintenance is the one Postgres-only part, and it is a no-op +elsewhere. The table names it builds come from dates this module computed; no +payload value ever reaches a formatted statement. +""" + +from __future__ import annotations + +import json +import logging +from collections import defaultdict +from datetime import date, datetime, timedelta, timezone + +from telemetry_server import config +from telemetry_server.store import PostgresStore, Store + +log = logging.getLogger(__name__) + +TOP_N = 25 + + +def _as_date(value: object) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, str): + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None + return None + + +async def run(store: Store, today: date | None = None) -> int: + """Recompute every metric. Returns how many rows were written.""" + today = today or datetime.now(timezone.utc).date() + written = 0 + written += await _activity(store) + written += await _retention(store) + written += await _shares(store, today) + written += await _ranks(store) + written += await _reliability(store, today) + written += await _agents(store, today) + await maintain_partitions(store, today) + return written + + +async def _activity(store: Store) -> int: + """DAU from ``install_days``; WAU and MAU by rolling the same rows.""" + rows = await store.fetch("SELECT install_id, day FROM install_days") + seen: dict[date, set] = defaultdict(set) + for install_id, day in rows: + parsed = _as_date(day) + if parsed is not None: + seen[parsed].add(install_id) + if not seen: + return 0 + + written = 0 + for day in sorted(seen): + for metric, window in (("dau", 1), ("wau", 7), ("mau", 30)): + active: set = set() + for offset in range(window): + active |= seen.get(day - timedelta(days=offset), set()) + await store.put_metric(day, metric, "", len(active)) + written += 1 + return written + + +async def _retention(store: Store) -> int: + """D1 / D7 / D30 from first-seen cohorts. + + The cohort day is ``MIN(day)`` in ``install_days`` rather than + ``installs.first_seen``, so retention keeps working for an install whose + row was upserted long after its first event. + """ + rows = await store.fetch("SELECT install_id, day FROM install_days") + days: dict[object, set] = defaultdict(set) + for install_id, day in rows: + parsed = _as_date(day) + if parsed is not None: + days[install_id].add(parsed) + if not days: + return 0 + + cohorts: dict[date, list] = defaultdict(list) + for install_id, active in days.items(): + cohorts[min(active)].append(install_id) + + written = 0 + for cohort_day, members in sorted(cohorts.items()): + await store.put_metric(cohort_day, "cohort_size", "", len(members)) + written += 1 + for horizon in (1, 7, 30): + target = cohort_day + timedelta(days=horizon) + retained = sum(1 for m in members if target in days[m]) + await store.put_metric(cohort_day, f"retention_d{horizon}", "", retained) + written += 1 + return written + + +async def _shares(store: Store, today: date) -> int: + """Version, OS and LLM-provider mix, as of the run. + + These describe the fleet now rather than a past day, so they are stamped + with the run date: a series of daily snapshots is exactly how "how long does + an upgrade take to propagate" gets answered. + """ + written = 0 + for metric, column in (("version_share", "version"), ("os_share", "os")): + rows = await store.fetch( + f"SELECT {column}, COUNT(*) FROM installs GROUP BY {column}" + ) + for value, count in rows: + await store.put_metric(today, metric, str(value or "unknown"), count) + written += 1 + + counts: dict[str, int] = defaultdict(int) + for (raw,) in await store.fetch("SELECT llm_providers FROM installs"): + for provider in _providers(raw): + counts[provider] += 1 + for provider, count in counts.items(): + await store.put_metric(today, "provider_share", provider, count) + written += 1 + return written + + +def _providers(raw: object) -> list[str]: + """One column, two shapes: a Postgres ``text[]`` or a SQLite JSON string.""" + if isinstance(raw, (list, tuple)): + return [str(p) for p in raw] + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except ValueError: + return [] + return [str(p) for p in parsed] if isinstance(parsed, list) else [] + return [] + + +#: The one property of each event that carries the signal worth ranking. +RANK_DIMS = { + "command": ("command_rank", "name"), + "action": ("action_rank", "module"), + "routine_run": ("routine_rank", "routine"), + "agent_turn": ("model_rank", "model"), + "feature_first_use": ("activation", "feature"), + "trade": ("trade_rank", "connector"), +} + + +async def _ranks(store: Store) -> int: + """What is actually used: event volume per day, then the key dimensions.""" + written = 0 + rows = await store.fetch( + f"SELECT {store.DAY} AS d, name, COUNT(*) FROM events GROUP BY d, name" + ) + for day, name, count in rows: + parsed = _as_date(day) + if parsed is not None: + await store.put_metric(parsed, "event_rank", str(name), count) + written += 1 + + for event_name, (metric, prop) in RANK_DIMS.items(): + dim = store.json_get("props", prop) + rows = await store.fetch( + f"SELECT {dim} AS v, COUNT(*) AS n FROM events WHERE name = $1 " + f"GROUP BY v ORDER BY n DESC", + event_name, + ) + today = datetime.now(timezone.utc).date() + for value, count in rows[:TOP_N]: + if value is None: + continue + await store.put_metric(today, metric, str(value), count) + written += 1 + return written + + +async def _reliability(store: Store, today: date) -> int: + """Error rate per install-day, the top failure groups, and honest drops.""" + written = 0 + rows = await store.fetch( + "SELECT day, SUM(events), SUM(errors) FROM install_days GROUP BY day" + ) + for day, events, errors in rows: + parsed = _as_date(day) + if parsed is None or not events: + continue + await store.put_metric(parsed, "error_rate", "", (errors or 0) / events) + written += 1 + + exc = store.json_get("props", "exc_type") + sig = store.json_get("props", "sig") + groups = await store.fetch( + f"SELECT {exc} AS e, {sig} AS s, COUNT(*) AS n FROM events " + f"WHERE name = $1 GROUP BY e, s ORDER BY n DESC", + "error", + ) + for exc_type, signature, count in groups[:TOP_N]: + await store.put_metric( + today, + "error_group", + f"{exc_type or 'unknown'}:{signature or 'unknown'}", + count, + ) + written += 1 + + service = store.json_get("props", "service") + upstream = await store.fetch( + f"SELECT {service} AS s, COUNT(*) AS n FROM events WHERE name = $1 GROUP BY s", + "upstream_error", + ) + for name, count in upstream: + await store.put_metric( + today, "upstream_error_rank", str(name or "other"), count + ) + written += 1 + + # Client-side rate limiting must never masquerade as "things got quieter", + # so the confession the envelope carries is charted next to the volume. + (dropped,) = (await store.fetch("SELECT SUM(dropped_total) FROM installs"))[0] + (total,) = (await store.fetch("SELECT COUNT(*) FROM events"))[0] + await store.put_metric(today, "dropped_total", "", dropped or 0) + await store.put_metric( + today, + "dropped_rate", + "", + (dropped or 0) / (total + (dropped or 0)) if total else 0, + ) + return written + 2 + + +#: ``(event name, property, metric)`` triples whose distribution answers "how +#: are agents used" — provider and model mix, dry-run versus live, and how +#: often a confirmation is actually granted. +AGENT_DIMS = ( + ("agent_turn", "provider", "agent_provider"), + ("agent_turn", "outcome", "agent_outcome"), + ("agent_turn", "kind", "agent_kind"), + ("strategy_run", "mode", "strategy_mode"), + ("confirmation", "decision", "confirmation_decision"), +) + +#: Averages worth a number rather than a distribution. +AGENT_AVERAGES = ( + ("agent_turn", "tool_calls", "agent_tool_calls_avg"), + ("agent_turn", "duration_ms", "agent_duration_ms_avg"), + ("routine_run", "duration_ms", "routine_duration_ms_avg"), +) + + +async def _agents(store: Store, today: date) -> int: + """Agent economics: the mix, the cost per turn, and the trust decisions.""" + written = 0 + for event_name, prop, metric in AGENT_DIMS: + dim = store.json_get("props", prop) + rows = await store.fetch( + f"SELECT {dim} AS v, COUNT(*) FROM events WHERE name = $1 GROUP BY v", + event_name, + ) + for value, count in rows: + await store.put_metric(today, metric, str(value or "unknown"), count) + written += 1 + + for event_name, prop, metric in AGENT_AVERAGES: + column = store.json_num("props", prop) + rows = await store.fetch( + f"SELECT AVG({column}) FROM events WHERE name = $1", event_name + ) + average = rows[0][0] if rows else None + if average is not None: + await store.put_metric(today, metric, "", float(average)) + written += 1 + return written + + +def _month_start(day: date) -> date: + return day.replace(day=1) + + +def _next_month(day: date) -> date: + return (day.replace(day=28) + timedelta(days=4)).replace(day=1) + + +async def maintain_partitions(store: Store, today: date) -> list[str]: + """Create next month's partition, drop what has aged out. + + Returns the partition names it touched, which is what the tests assert on. + Postgres only: SQLite has no partitioning, and at the volume SQLite is used + for, ``DELETE`` is the right answer anyway. + """ + if not isinstance(store, PostgresStore): + cutoff = today - timedelta(days=config.retention_days()) + await store.execute("DELETE FROM events WHERE received_at < $1", cutoff) + return [] + + touched = [] + current = _month_start(today) + for start in (current, _next_month(current)): + end = _next_month(start) + name = f"events_{start:%Y_%m}" + await store.execute( + f"CREATE TABLE IF NOT EXISTS {name} PARTITION OF events " + f"FOR VALUES FROM ('{start:%Y-%m-%d}') TO ('{end:%Y-%m-%d}')" + ) + touched.append(name) + + cutoff = _month_start(today - timedelta(days=config.retention_days())) + existing = await store.fetch( + "SELECT c.relname FROM pg_class c " + "JOIN pg_inherits i ON i.inhrelid = c.oid " + "JOIN pg_class p ON p.oid = i.inhparent WHERE p.relname = 'events'" + ) + for (name,) in existing: + try: + start = datetime.strptime(name.removeprefix("events_"), "%Y_%m").date() + except ValueError: + continue + if start < cutoff: + await store.execute(f"DROP TABLE IF EXISTS {name}") + touched.append(f"-{name}") + return touched From 6d47a1933ae09038c0ec377c44f4106afe76fdc2 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 00:01:06 +0300 Subject: [PATCH 018/116] Make the collector deployable, and prove it holds under a hostile POST (FEAT-024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose brings up collector + postgres + grafana. Only the collector is meant to be reachable, and only through a reverse proxy that terminates TLS — the service speaks plain HTTP inside the network, matching how the hummingbot/deploy stack is already run. Postgres and Grafana bind to loopback: Grafana is the one component here with a login and a session cookie, and it has no business sharing an exposure with an endpoint whose whole design is that anyone may POST to it. The passwords have no defaults; compose refuses to start without them. The collector does not run as root. Dashboards are not auto-provisioned, only the datasource. The queries are the deliverable and a panel is a paste. The tests are the other half of the feature. The first one is the load-bearing one: it builds its envelope by running the real emitter from condor/telemetry/ and handing the result to context.envelope(), the same path a live install takes. If FEAT-023 ever changes shape, this suite fails instead of the collector silently dropping a field in production. That test is also what caught the four places where the shipped client differs from this feature's written design — event ids are uuid4().hex rather than dashed, config carries three capability flags the design's installs table did not have, level is only ever ping|usage, and mcp_tool exists. The wire contract now follows the client. Most of the rest is negative, because that is what a public unauthenticated endpoint needs asserted: an oversized body (with and without a truthful Content-Length), a batch over the cap, a rate-limited caller and five shapes of malformed envelope each have to be refused *without the database being reached at all* — checked with a store that raises if it is touched. A refusal must not echo a canary planted in the request. A SQL-injection-shaped version string must land as inert text with the events table still standing. An absurd count must clamp. The limiter's key space must stay bounded under 5,000 attacker-chosen ids. Idempotency gets its own tests from both directions: the same envelope twice stores one copy and leaves install_days unmoved, and a duplicate id inside a single envelope is counted once. Everything runs against SQLite in a tmp_path inside `uv run pytest` — no daemon, no port, no container, nothing left behind. 1646 pass, up from 1612. --- telemetry_server/.env.example | 4 + telemetry_server/Dockerfile | 42 ++ telemetry_server/README.md | 183 ++++++ telemetry_server/docker-compose.yml | 75 +++ telemetry_server/grafana/datasource.yml | 27 + tests/test_telemetry_server.py | 749 ++++++++++++++++++++++++ 6 files changed, 1080 insertions(+) create mode 100644 telemetry_server/.env.example create mode 100644 telemetry_server/Dockerfile create mode 100644 telemetry_server/README.md create mode 100644 telemetry_server/docker-compose.yml create mode 100644 telemetry_server/grafana/datasource.yml create mode 100644 tests/test_telemetry_server.py diff --git a/telemetry_server/.env.example b/telemetry_server/.env.example new file mode 100644 index 00000000..44d6900d --- /dev/null +++ b/telemetry_server/.env.example @@ -0,0 +1,4 @@ +# Copy to .env beside docker-compose.yml. Both are required; compose refuses to +# start without them rather than defaulting to something guessable. +POSTGRES_PASSWORD=change-me +GRAFANA_PASSWORD=change-me diff --git a/telemetry_server/Dockerfile b/telemetry_server/Dockerfile new file mode 100644 index 00000000..fa16ac1f --- /dev/null +++ b/telemetry_server/Dockerfile @@ -0,0 +1,42 @@ +# The collector image. +# +# It builds from the Condor repository root rather than from this directory, +# because ingest.py imports `condor.telemetry.schema` — the same taxonomy the +# client sanitises with — and that import is the whole reason the collector +# lives in this repo (FEAT-024). The cost is an image that carries Condor's +# dependency set rather than four packages; the benefit is that the emitter and +# the validator cannot drift apart. That trade was made deliberately. +# +# Build from the repo root: docker build -f telemetry_server/Dockerfile . + +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app + +# Dependencies first so a code change does not re-resolve the world. +COPY pyproject.toml uv.lock* README.md ./ +RUN uv sync --extra telemetry-server --no-dev --no-install-project + +COPY condor/ ./condor/ +COPY telemetry_server/ ./telemetry_server/ +COPY utils/ ./utils/ +COPY config_manager.py ./ + +# Nothing here needs to be root, and an unauthenticated public endpoint is the +# last process that should be. +RUN useradd --system --uid 10001 collector && chown -R collector /app +USER collector + +EXPOSE 8000 + +# Plain HTTP on purpose: TLS terminates at the reverse proxy in front, matching +# how the hummingbot/deploy stack is already run. +CMD ["uv", "run", "--no-sync", "uvicorn", "telemetry_server.app:create_app", \ + "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/telemetry_server/README.md b/telemetry_server/README.md new file mode 100644 index 00000000..e935687b --- /dev/null +++ b/telemetry_server/README.md @@ -0,0 +1,183 @@ +# Condor telemetry collector + +The receiving end of [`condor/telemetry/`](../condor/telemetry/) (FEAT-023). +Consenting installs POST batched, anonymous envelopes here; this service +validates them against the client's own taxonomy, stores them idempotently, +rolls them up nightly, and leaves five SQL files behind that answer the +questions the exercise exists for. + +It is not installed by default and it is not running anywhere. Standing it up is +a deliberate act. + +## Why it lives in this repository + +`ingest.py` imports `condor.telemetry.schema` — the *same module* the emitter +sanitises with. A separate collector repo would be tidier operationally but +would force the event taxonomy to exist in two places, and a taxonomy that +drifts between emitter and validator is the classic failure of this kind of +system: the client starts sending a property, the server silently drops it, and +nobody notices for three months. Here, drift is not unlikely — it is impossible, +and `tests/test_telemetry_server.py` asserts there is no second copy in the repo. + +The cost is a container image carrying Condor's dependency set rather than four +packages. That trade was made knowingly. + +## Endpoints + +| Method | Path | Behaviour | +|---|---|---| +| `POST` | `/v1/events` | Ingest one envelope → `202 {"accepted": n, "rejected": m, "duplicates": d}` | +| `GET` | `/health` | Liveness plus a real database round trip | + +There is no endpoint that reads data back to an install, and no API docs are +served: a schema browser on an unauthenticated endpoint is surface with no +reader. + +## Surviving the open internet + +Installs are anonymous by design, so there is no credential to issue without +creating the identity we explicitly do not want. The endpoint is therefore +public and unauthenticated, and the pipeline is ordered so that everything cheap +happens before anything expensive: + +1. **Body cap — 1 MB.** Counted as the stream arrives. `Content-Length` is + checked first because it is free, but it is not trusted: a chunked request + can omit or understate it. +2. **Rate limit by source IP** — token bucket, 60/h, *before* the JSON parser + runs. `X-Forwarded-For` is ignored unless `TELEMETRY_TRUSTED_PROXY` is set, + because otherwise the header is a one-line rate-limit bypass. The limiter's + key space is capped and evicts LRU, so an attacker-chosen `install_id` cannot + become a memory-exhaustion primitive. +3. **Envelope validation** — unknown `schema` versions, unknown `level`s, a + malformed `install_id` and batches over 500 events are refused whole. +4. **Event validation** against `condor.telemetry.schema`. An unknown event name + or an out-of-spec property is **dropped and counted**, and the rest of the + batch is still accepted — rejecting a whole envelope over one malformed event + would let a single client bug erase a day of otherwise good data. The + `rejected` counter is the early warning that a taxonomy change broke + something, so chart it. +5. **Persist** in one transaction, `ON CONFLICT DO NOTHING`. + +Two invariants hold throughout. **No payload value is ever formatted into SQL** — +`store.py` takes bound parameters only, and the only interpolation anywhere is +partition names built from dates the rollup computed itself. **No error response +echoes any part of the request** — every refusal is a fixed string, so this +endpoint cannot be turned into a reflector or used as an oracle. + +Unknown fields inside `app` and `config` are not stored, not logged, and not +reflected. They simply do not exist here. + +### What this does *not* defend against + +Anyone can POST fabricated envelopes with random `install_id`s and skew the +adoption numbers. Rate limits raise the cost; the real defence is that the payoff +is nil and the data steers internal direction, not anything with money attached. +If it ever becomes a problem the fix is a signed `install_id` issued on first +contact — deliberately not built now. + +## Storage + +`migrations/001_init.sql` (Postgres) and `001_init.sqlite.sql` (SQLite) define +the same four tables: + +- **`installs`** — one upserted row per install: identity, platform, capability + flags, counts, and a running `dropped_total`. +- **`events`** — append-only, partitioned by month in Postgres so the 90-day + retention is a `DROP TABLE` rather than a mass `DELETE`. +- **`install_days`** — written on the **ingest path**, from newly inserted rows + only. This is what every retention question depends on and what survives raw + expiry, which is why it is not deferred to the rollup. +- **`daily_metrics`** — the nightly rollup. Permanent. + +Both `ts` and `received_at` are kept. Client clocks on self-hosted boxes drift +and occasionally lie, so ingest clamps `ts` to `received_at ± 48 h`; dashboards +count volume by `received_at` and trust `ts` only for ordering inside one +install's session. + +**SQLite is a real backend, not a mock.** The collector's tests run in Condor's +own `uv run pytest` with no database daemon, against the same statements +production executes. Postgres is what you deploy; SQLite is what keeps the tests +honest and runnable. + +## Rollups + +`rollup.py` runs on an interval inside the collector process (a plain asyncio +loop — the job is "every few hours, never concurrently with itself", and one +service with no extra runtime is easier to reason about than one with a +scheduler in it). SQL does the grouping; Python does the date arithmetic and +cohort matching, which is what lets one rollup run unchanged against both +backends. + +It writes `dau`/`wau`/`mau`, `cohort_size` + `retention_d1/d7/d30`, +`version_share`/`os_share`/`provider_share`, event and command/action/routine +ranks, `error_rate` and `error_group`, the agent mix and per-turn cost, and +`dropped_total`/`dropped_rate` — so client-side rate limiting never masquerades +as "things got quieter". It also creates next month's partition and drops what +has aged past `TELEMETRY_RETENTION_DAYS`. + +Rollups exist from day one rather than "later" because backfilling one after +dropping the raw data it came from is impossible. + +## The five questions + +One file each in `queries/`, each runnable standalone against Postgres *or* the +SQLite the tests seed, and each readable without Grafana: + +| File | Question | +|---|---| +| `adoption.sql` | How many installs are alive, on what, and how fast do upgrades spread? | +| `retention.sql` | Do people stay? D1 / D7 / D30 by cohort. | +| `feature_usage.sql` | What is actually used, and what are we maintaining for nobody? | +| `reliability.sql` | What is broken out there, and against which upstream? | +| `agent_economics.sql` | Which providers and models, at what cost per turn, dry-run vs live? | + +No query groups by `user_hash`. It is salted per install, so it can be counted +inside one install but is meaningless across them, and grouping by it globally +would invent a cross-install identity the client deliberately refused to +provide. A test asserts this. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `TELEMETRY_DSN` | `sqlite:///telemetry.db` | Postgres URL in production; anything else is a SQLite path. | +| `TELEMETRY_TRUSTED_PROXY` | unset | Believe `X-Forwarded-For`. Only set this when a proxy really is in front. | +| `TELEMETRY_RETENTION_DAYS` | `90` | How long raw events live. Rollups are permanent regardless. | +| `TELEMETRY_ROLLUP_INTERVAL_S` | `21600` | How often the rollup runs. | + +## Running it + +```bash +cp .env.example .env # set POSTGRES_PASSWORD and GRAFANA_PASSWORD +docker compose -f telemetry_server/docker-compose.yml up -d +``` + +Only `collector` should be reachable from the internet, and only through a +reverse proxy that terminates TLS — the service speaks plain HTTP inside the +compose network, matching how the `hummingbot/deploy` stack is already run. +Postgres and Grafana bind to loopback. **Grafana is the weakest operational link +here**: it is the only component with a login and a session cookie, so keep it +behind the same proxy or VPN as the rest, never exposed alongside ingest. + +`grafana/datasource.yml` provisions the Postgres datasource. Dashboards are not +auto-provisioned — the `queries/*.sql` files are the deliverable and a panel is +a paste. + +Once it is up, point an install at it: + +```bash +CONDOR_TELEMETRY_URL=https://telemetry.example.org/v1/events +``` + +## Tests + +```bash +uv run pytest tests/test_telemetry_server.py +``` + +They run against SQLite in a `tmp_path` and never bind a port, start a +container, or leave anything behind. The first test is the important one: it +builds its envelope by running the **real emitter** from `condor/telemetry/` and +handing the result to `context.envelope()`, so if FEAT-023 ever changes shape, +this suite fails instead of the collector silently dropping a field in +production. diff --git a/telemetry_server/docker-compose.yml b/telemetry_server/docker-compose.yml new file mode 100644 index 00000000..4f09a9a1 --- /dev/null +++ b/telemetry_server/docker-compose.yml @@ -0,0 +1,75 @@ +# The collector stack: ingest, its database, and the dashboards. +# +# Only `collector` is meant to be reachable from the internet, and only through +# a reverse proxy that terminates TLS. Postgres and Grafana are bound to +# loopback: Grafana is the one component here with a login and a session cookie, +# and it has no business sharing an exposure with an endpoint whose whole design +# is that anyone may POST to it. +# +# docker compose -f telemetry_server/docker-compose.yml up -d +# +# Set POSTGRES_PASSWORD and GRAFANA_PASSWORD in an .env file beside this one. + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: telemetry + POSTGRES_USER: telemetry + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + volumes: + - telemetry-db:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U telemetry -d telemetry"] + interval: 10s + timeout: 5s + retries: 5 + + collector: + build: + context: .. + dockerfile: telemetry_server/Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + TELEMETRY_DSN: postgresql://telemetry:${POSTGRES_PASSWORD}@postgres:5432/telemetry + # X-Forwarded-For is only believed because there really is a proxy in + # front here. Leave this unset on a directly-exposed deployment. + TELEMETRY_TRUSTED_PROXY: "true" + TELEMETRY_RETENTION_DAYS: "90" + ports: + - "127.0.0.1:8000:8000" + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 30s + timeout: 5s + retries: 3 + + grafana: + image: grafana/grafana:11.3.0 + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:?set GRAFANA_PASSWORD} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_AUTH_ANONYMOUS_ENABLED: "false" + TELEMETRY_DB_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - grafana-data:/var/lib/grafana + - ./grafana:/etc/grafana/provisioning/datasources:ro + # The five queries, readable inside the container so a panel can be built + # by pasting one in. They are the deliverable; the panels are decoration. + - ./queries:/etc/telemetry-queries:ro + ports: + - "127.0.0.1:3000:3000" + +volumes: + telemetry-db: + grafana-data: diff --git a/telemetry_server/grafana/datasource.yml b/telemetry_server/grafana/datasource.yml new file mode 100644 index 00000000..abadd044 --- /dev/null +++ b/telemetry_server/grafana/datasource.yml @@ -0,0 +1,27 @@ +# Grafana reads the collector's Postgres directly. There is no API in between: +# the queries in ../queries/ are plain SQL against the rollup tables, so a panel +# is a paste rather than an integration. +# +# The account is read-only by intent. Grant it explicitly after first boot: +# CREATE USER grafana WITH PASSWORD '...'; +# GRANT CONNECT ON DATABASE telemetry TO grafana; +# GRANT USAGE ON SCHEMA public TO grafana; +# GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana; + +apiVersion: 1 + +datasources: + - name: Telemetry + type: postgres + uid: condor-telemetry + access: proxy + url: postgres:5432 + database: telemetry + user: telemetry + secureJsonData: + password: ${TELEMETRY_DB_PASSWORD} + jsonData: + sslmode: disable + postgresVersion: 1600 + isDefault: true + editable: false diff --git a/tests/test_telemetry_server.py b/tests/test_telemetry_server.py new file mode 100644 index 00000000..9031af93 --- /dev/null +++ b/tests/test_telemetry_server.py @@ -0,0 +1,749 @@ +"""The collector (FEAT-024) — the tests that make a public endpoint survivable. + +Three things are being proven here, in descending order of how much they matter. + +**The wire contract is the client's, not a hand-copy.** The first test builds +its envelope by running the real emitter from ``condor/telemetry/`` and handing +the result to ``context.envelope()`` — the same code path a live install uses. +If FEAT-023 ever changes shape, this file fails rather than the collector +silently dropping a field in production. + +**Untrusted input cannot do damage.** This endpoint is unauthenticated and +public by design, so most of what follows is negative: an oversized body, an +over-long batch, a rate-limited caller and a malformed envelope each have to be +refused *without touching the database*, and no refusal may echo any part of +the request back. + +**Idempotency is real.** The client retries after a timeout. If a retry could +inflate a count, every adoption number the exercise exists to produce would be +unreliable. + +The whole suite runs against SQLite in a ``tmp_path``. Nothing here binds a +port, starts a container, or outlives the test. +""" + +from __future__ import annotations + +import asyncio +import json +import re +import tomllib +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from telemetry_server import ingest, rollup +from telemetry_server.app import create_app +from telemetry_server.store import open_store + +REPO = Path(__file__).resolve().parent.parent +QUERIES = REPO / "telemetry_server" / "queries" + + +# ── Harness ────────────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def fresh_limits(): + """The limiters are process-wide; a leftover bucket would leak between tests.""" + ingest.by_ip.reset() + ingest.by_install.reset() + yield + ingest.by_ip.reset() + ingest.by_install.reset() + + +@pytest.fixture +def store(tmp_path): + store = asyncio.run(open_store(str(tmp_path / "telemetry.db"))) + yield store + asyncio.run(store.close()) + + +@pytest.fixture +def client(store): + with TestClient(create_app(store)) as client: + yield client + + +class Spy: + """A store that refuses to be used, so "no database contact" is checkable.""" + + def __init__(self): + self.calls = 0 + + async def record(self, *args, **kwargs): + self.calls += 1 + raise AssertionError("the database was reached for a request we refuse") + + async def ping(self): + return True + + async def close(self): + return None + + +@pytest.fixture +def spy_client(): + spy = Spy() + with TestClient(create_app(spy)) as client: + yield client, spy + + +def _event(name="command", props=None, ts=None, event_id=None): + return { + "id": event_id or uuid.uuid4().hex, + "ts": ts or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "name": name, + "props": ( + {"name": "portfolio", "surface": "telegram"} if props is None else props + ), + } + + +def _envelope(events=None, **overrides): + """The shape condor/telemetry/context.py actually puts on the wire.""" + envelope = { + "schema": 1, + "install_id": uuid.uuid4().hex, + "level": "usage", + "sent_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "app": { + "version": "54ad4dc", + "branch": "main", + "python": "3.12", + "os": "linux", + "arch": "arm64", + "in_docker": True, + }, + "config": { + "has_web": True, + "has_gateway": False, + "has_hb_api": True, + "llm_providers": ["openai", "openrouter"], + "user_count": 3, + "server_count": 2, + "agent_count": 4, + }, + "dropped": 0, + "events": [_event()] if events is None else events, + } + envelope.update(overrides) + return envelope + + +def _rows(store, sql, *args): + return asyncio.run(store.fetch(sql, *args)) + + +# ── The contract is the client's ───────────────────────────────────────── + + +def test_an_envelope_built_by_the_real_client_is_accepted_whole( + client, store, tmp_path, monkeypatch +): + """The acceptance criterion that matters: a genuine FEAT-023 envelope lands. + + Nothing here hand-writes the wire format. The events come out of the real + emitter and the envelope out of ``context.envelope``, so this test is the + thing that fails if emitter and collector ever drift apart. + """ + import config_manager as cm_module + from condor.telemetry import consent, context, emitter + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("utils.config.CONDOR_TELEMETRY", None, raising=False) + monkeypatch.setattr("utils.config.CONDOR_TELEMETRY_URL", None, raising=False) + monkeypatch.setattr( + "condor.agents.agent._DATA_ROOT", str(tmp_path / "data"), raising=False + ) + cm_module.ConfigManager.reset_instance() + emitter.discard_buffer() + emitter.set_hosted(True) + consent.refresh() + try: + cm_module.get_config_manager() + consent.grant("usage") + + emitter.emit("install") + emitter.emit("command", name="portfolio", surface="telegram", authorized=True) + emitter.emit("action", module="bots", verb="deploy", surface="web") + emitter.emit("trade", venue="cex", connector="binance", side="buy") + emitter.emit( + "agent_turn", kind="chat", provider="openai", model="gpt-5", tool_calls=3 + ) + emitter.emit("error", where="handlers.bots", exc_type="ValueError", sig="ab12") + + events, dropped = emitter.drain() + envelope = context.envelope(events, dropped, consent.level()) + finally: + cm_module.ConfigManager.reset_instance() + emitter.discard_buffer() + consent.refresh() + + response = client.post("/v1/events", json=envelope) + + assert response.status_code == 202 + assert response.json() == { + "accepted": len(events), + "rejected": 0, + "duplicates": 0, + } + + stored = _rows(store, "SELECT name FROM events ORDER BY name") + assert sorted(n for (n,) in stored) == sorted(e["name"] for e in events) + + # The install context lands as columns, including the capability flags the + # client sends but the original design's table did not have. + (row,) = _rows( + store, + "SELECT level, version, branch, os, in_docker, has_hb_api, has_web, " + "user_count, llm_providers FROM installs", + ) + assert row[0] == "usage" + assert row[1] == envelope["app"]["version"] + assert json.loads(row[8]) == envelope["config"]["llm_providers"] + + +def test_the_taxonomy_has_exactly_one_definition_in_the_repo(): + """``ingest`` validates against the emitter's own module, not a copy.""" + source = (REPO / "telemetry_server" / "ingest.py").read_text(encoding="utf-8") + assert "from condor.telemetry import schema" in source + + definitions = [] + for path in REPO.rglob("*.py"): + if any(part in {".venv", "node_modules", "__pycache__"} for part in path.parts): + continue + if re.search( + r"^EVENTS(\s*:\s*[^=]+)?\s*=", path.read_text(encoding="utf-8"), re.M + ): + definitions.append(path.relative_to(REPO).as_posix()) + + assert definitions == ["condor/telemetry/schema.py"] + + +# ── Idempotency and partial acceptance ─────────────────────────────────── + + +def test_the_same_envelope_twice_stores_one_copy(client, store): + """The client retries after a timeout; a retry must not inflate a metric.""" + envelope = _envelope([_event(), _event(), _event()]) + + first = client.post("/v1/events", json=envelope) + second = client.post("/v1/events", json=envelope) + + assert first.json()["accepted"] == 3 + assert second.json() == {"accepted": 0, "rejected": 0, "duplicates": 3} + + assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 3 + # install_days is incremented from newly inserted rows only, so the rollup + # tables cannot be inflated by a retry either. + assert _rows(store, "SELECT SUM(events) FROM install_days")[0][0] == 3 + + +def test_one_unknown_name_and_one_stray_prop_cost_two_rejections(client, store): + """Partial acceptance: a single client bug must not erase a good batch.""" + envelope = _envelope( + [ + _event(), + _event(name="a_command_from_a_newer_client"), + _event(props={"name": "bots", "surface": "web", "wallet": "0xdeadbeef"}), + ] + ) + + response = client.post("/v1/events", json=envelope) + + assert response.status_code == 202 + assert response.json()["rejected"] == 2 + assert response.json()["accepted"] == 2 + + (props,) = _rows(store, "SELECT props FROM events WHERE name = $1", "command")[1] + assert "wallet" not in props + assert "0xdeadbeef" not in props + + +def test_a_duplicate_id_inside_one_envelope_is_counted_once(client, store): + shared = uuid.uuid4().hex + envelope = _envelope([_event(event_id=shared), _event(event_id=shared)]) + + assert client.post("/v1/events", json=envelope).json() == { + "accepted": 1, + "rejected": 1, + "duplicates": 0, + } + assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 1 + + +def test_a_clock_from_next_year_is_clamped_not_stored(client, store): + """Self-hosted clocks drift and occasionally lie. Believe them within 48h.""" + future = (datetime.now(timezone.utc) + timedelta(days=365)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + past = (datetime.now(timezone.utc) - timedelta(days=400)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + client.post("/v1/events", json=_envelope([_event(ts=future), _event(ts=past)])) + + stored = [ + datetime.fromisoformat(ts) for (ts,) in _rows(store, "SELECT ts FROM events") + ] + now = datetime.now(timezone.utc) + for ts in stored: + assert abs(ts - now) <= ingest.CLOCK_SLACK + timedelta(minutes=1) + + +# ── Untrusted input ────────────────────────────────────────────────────── + + +def test_an_oversized_body_is_refused_without_a_query(spy_client): + client, spy = spy_client + body = b'{"schema":1,"pad":"' + b"a" * (ingest.MAX_BODY_BYTES + 1024) + b'"}' + + response = client.post( + "/v1/events", content=body, headers={"content-type": "application/json"} + ) + + assert response.status_code == 413 + assert response.json() == {"error": "body_too_large"} + assert spy.calls == 0 + + +def test_an_oversized_body_is_refused_even_without_a_content_length(): + """Content-Length is the caller's claim, so the stream is counted as it arrives.""" + + class Chunked: + headers: dict = {} + client = None + + async def stream(self): + for _ in range(4): + yield b"a" * (ingest.MAX_BODY_BYTES // 3) + + with pytest.raises(ingest.Refused) as refusal: + asyncio.run(ingest.read_body(Chunked())) + assert refusal.value.reason == "body_too_large" + + +def test_a_ten_thousand_event_envelope_is_refused_without_a_query(spy_client): + """Two caps guard this, and the outer one bites first. + + Ten thousand events cannot fit inside a 1 MB body, so such an envelope is + refused while it is still bytes on a socket — a stronger guarantee than the + event-count cap. The count cap is what catches the envelope that *does* fit, + which is asserted separately below. + """ + client, spy = spy_client + + response = client.post( + "/v1/events", json=_envelope([_event() for _ in range(10_000)]) + ) + + assert response.status_code == 413 + assert response.json()["error"] in ("body_too_large", "too_many_events") + assert spy.calls == 0 + + +def test_more_events_than_the_cap_are_refused_without_a_query(spy_client): + client, spy = spy_client + oversized = [_event() for _ in range(ingest.MAX_EVENTS + 1)] + + response = client.post("/v1/events", json=_envelope(oversized)) + + assert response.status_code == 413 + assert response.json() == {"error": "too_many_events"} + assert spy.calls == 0 + + +@pytest.mark.parametrize( + "envelope, reason", + [ + ( + { + "schema": 99, + "install_id": uuid.uuid4().hex, + "level": "usage", + "events": [], + }, + "unknown_schema", + ), + ( + {"schema": 1, "install_id": "not-a-uuid", "level": "usage", "events": []}, + "invalid_envelope", + ), + ( + { + "schema": 1, + "install_id": uuid.uuid4().hex, + "level": "root", + "events": [], + }, + "invalid_envelope", + ), + ( + { + "schema": 1, + "install_id": uuid.uuid4().hex, + "level": "usage", + "events": {}, + }, + "invalid_envelope", + ), + ([1, 2, 3], "invalid_envelope"), + ], +) +def test_a_malformed_envelope_is_refused_without_a_query(spy_client, envelope, reason): + client, spy = spy_client + + response = client.post("/v1/events", json=envelope) + + assert response.status_code == 400 + assert response.json() == {"error": reason} + assert spy.calls == 0 + + +def test_a_refusal_never_echoes_the_request(spy_client): + """No reflector, and no oracle for what the server did with a probe.""" + marker = "canary-9f13ab-do-not-reflect" + client, _ = spy_client + + responses = [ + client.post("/v1/events", content=marker.encode()), + client.post("/v1/events", json={"schema": 1, "install_id": marker}), + client.post("/v1/events", json=_envelope(install_id=marker)), + ] + + for response in responses: + assert response.status_code in (400, 413) + assert marker not in response.text + assert set(response.json()) == {"error"} + + +def test_body_that_is_not_json_is_refused(spy_client): + client, spy = spy_client + response = client.post("/v1/events", content=b"{not json at all") + assert response.status_code == 400 + assert response.json() == {"error": "invalid_json"} + assert spy.calls == 0 + + +def test_exceeding_the_ip_rate_limit_returns_429_without_a_query(spy_client): + """The limiter runs before the JSON parser, so a flood costs us nothing.""" + client, spy = spy_client + for _ in range(ingest.RATE_PER_HOUR): + ingest.by_ip.allow("testclient") + + response = client.post("/v1/events", json=_envelope()) + + assert response.status_code == 429 + assert response.json() == {"error": "rate_limited"} + assert spy.calls == 0 + + +def test_a_flood_from_one_install_is_limited_even_across_addresses(spy_client): + """The per-install bucket is the one an attacker cannot rotate away from.""" + client, spy = spy_client + install_id = str(uuid.uuid4()) + for _ in range(ingest.RATE_PER_HOUR): + ingest.by_install.allow(install_id) + + response = client.post("/v1/events", json=_envelope(install_id=install_id)) + + assert response.status_code == 429 + assert spy.calls == 0 + + +def test_the_limiter_key_space_is_bounded(spy_client): + """An attacker-chosen key must not be a memory exhaustion primitive.""" + limiter = ingest.RateLimiter(per_hour=60, max_keys=32) + for index in range(5_000): + limiter.allow(f"install-{index}") + assert len(limiter._buckets) <= 32 + + +def test_a_forwarded_header_is_ignored_unless_a_proxy_is_trusted(monkeypatch): + """Honouring X-Forwarded-For by default would be a one-header limit bypass.""" + + class Request: + headers = {"x-forwarded-for": "1.2.3.4"} + + class client: + host = "10.0.0.1" + + monkeypatch.delenv("TELEMETRY_TRUSTED_PROXY", raising=False) + assert ingest.client_ip(Request()) == "10.0.0.1" + + monkeypatch.setenv("TELEMETRY_TRUSTED_PROXY", "true") + assert ingest.client_ip(Request()) == "1.2.3.4" + + +def test_a_payload_cannot_reach_the_sql(client, store): + """Every value is a bound parameter; nothing is formatted into a statement.""" + injection = "1'); DROP TABLE events; --" + envelope = _envelope() + envelope["app"]["version"] = injection + envelope["config"]["llm_providers"] = [injection] + + assert client.post("/v1/events", json=envelope).status_code == 202 + + assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 1 + (version,) = _rows(store, "SELECT version FROM installs")[0] + assert "DROP TABLE" not in version + + +def test_unknown_envelope_fields_are_dropped_not_stored(client, store): + envelope = _envelope() + envelope["app"]["hostname"] = "trading-box.local" + envelope["config"]["api_key"] = "sk-live-secret" + envelope["surprise"] = {"nested": "value"} + + assert client.post("/v1/events", json=envelope).status_code == 202 + + dump = json.dumps(_rows(store, "SELECT * FROM installs")) + assert "trading-box" not in dump + assert "sk-live-secret" not in dump + + +def test_absurd_counts_are_clamped(client, store): + envelope = _envelope() + envelope["config"]["user_count"] = 10**18 + envelope["dropped"] = 10**18 + + client.post("/v1/events", json=envelope) + + (users, dropped) = _rows(store, "SELECT user_count, dropped_total FROM installs")[0] + assert users == ingest.MAX_COUNT + assert dropped == ingest.MAX_DROPPED + + +# ── Health ─────────────────────────────────────────────────────────────── + + +def test_health_reports_the_database(client): + assert client.get("/health").json() == {"status": "ok"} + + +def test_health_degrades_when_the_database_is_gone(): + class Broken: + async def ping(self): + raise RuntimeError("connection refused") + + async def close(self): + return None + + with TestClient(create_app(Broken())) as client: + response = client.get("/health") + assert response.status_code == 503 + assert response.json() == {"status": "degraded"} + + +# ── Rollups and queries ────────────────────────────────────────────────── + + +def _seed_cohorts(store): + """Three installs with hand-checkable retention, written straight to the + rollup table so the arithmetic under test is the rollup's, not ingest's.""" + base = datetime(2026, 7, 1, tzinfo=timezone.utc).date() + plan = { + "aaaaaaaa-0000-4000-8000-000000000001": [0, 1, 7], # retained at D1 and D7 + "aaaaaaaa-0000-4000-8000-000000000002": [0, 1], # D1 only + "aaaaaaaa-0000-4000-8000-000000000003": [0], # never came back + } + for install_id, offsets in plan.items(): + for offset in offsets: + asyncio.run( + store.execute( + "INSERT INTO install_days (install_id, day, events, errors, version)" + " VALUES ($1, $2, $3, $4, $5)", + install_id, + base + timedelta(days=offset), + 10, + 2 if offset == 0 else 0, + "54ad4dc", + ) + ) + return base + + +def test_retention_is_computed_from_the_permanent_table(store): + base = _seed_cohorts(store) + asyncio.run(rollup.run(store, today=base + timedelta(days=40))) + + def metric(name): + rows = _rows( + store, + "SELECT value FROM daily_metrics WHERE day = $1 AND metric = $2", + base.isoformat(), + name, + ) + return rows[0][0] if rows else None + + assert metric("cohort_size") == 3 + assert metric("retention_d1") == 2 + assert metric("retention_d7") == 1 + assert metric("retention_d30") == 0 + assert metric("dau") == 3 + assert metric("error_rate") == pytest.approx(6 / 30) + + +def test_retention_survives_the_loss_of_the_raw_events(store): + """install_days is written on the ingest path precisely so this holds.""" + base = _seed_cohorts(store) + asyncio.run(rollup.run(store, today=base + timedelta(days=40))) + before = _rows(store, "SELECT COUNT(*) FROM daily_metrics")[0][0] + + asyncio.run(store.execute("DELETE FROM events")) + asyncio.run(rollup.run(store, today=base + timedelta(days=40))) + + assert _rows(store, "SELECT COUNT(*) FROM daily_metrics")[0][0] >= before - 5 + assert ( + _rows( + store, + "SELECT value FROM daily_metrics WHERE metric = $1 AND day = $2", + "retention_d7", + base.isoformat(), + )[0][0] + == 1 + ) + + +def test_the_rollup_summarises_a_real_ingest(client, store): + client.post( + "/v1/events", + json=_envelope( + [ + _event(), + _event(props={"name": "bots", "surface": "telegram"}), + _event( + name="agent_turn", + props={ + "kind": "chat", + "provider": "openai", + "model": "gpt-5", + "tool_calls": 4, + "outcome": "done", + }, + ), + _event( + name="error", + props={ + "where": "handlers.bots", + "exc_type": "ValueError", + "sig": "ab12", + }, + ), + _event( + name="confirmation", props={"tool": "execute", "decision": "allow"} + ), + ] + ), + ) + asyncio.run(rollup.run(store)) + + metrics = { + (metric, dim): value + for metric, dim, value in _rows( + store, "SELECT metric, dim, value FROM daily_metrics" + ) + } + assert metrics[("event_rank", "command")] == 2 + assert metrics[("command_rank", "bots")] == 1 + assert metrics[("agent_provider", "openai")] == 1 + assert metrics[("agent_tool_calls_avg", "")] == 4 + assert metrics[("confirmation_decision", "allow")] == 1 + assert metrics[("error_group", "ValueError:ab12")] == 1 + assert metrics[("version_share", "54ad4dc")] == 1 + + +def _statements(path): + """Split a query file into runnable statements. + + Comments come out first: they are prose, and prose contains semicolons. + """ + body = "\n".join( + line + for line in path.read_text(encoding="utf-8").splitlines() + if not line.strip().startswith("--") + ) + return [s.strip() for s in body.split(";") if s.strip()] + + +def test_every_query_runs_and_answers_its_question(client, store): + base = _seed_cohorts(store) + client.post("/v1/events", json=_envelope()) + asyncio.run(rollup.run(store, today=base + timedelta(days=40))) + + results = {} + for path in sorted(QUERIES.glob("*.sql")): + results[path.name] = [_rows(store, sql) for sql in _statements(path)] + + assert set(results) == { + "adoption.sql", + "agent_economics.sql", + "feature_usage.sql", + "reliability.sql", + "retention.sql", + } + # Each one has to return numbers, not merely parse. + assert any(row[1] == "dau" for row in results["adoption.sql"][0]) + assert (base.isoformat(), 3, 2, 1, 0) in [ + tuple(row) for row in results["retention.sql"][0] + ] + assert any(row[1] == "command" for row in results["feature_usage.sql"][0]) + assert results["reliability.sql"][0] + assert results["agent_economics.sql"] + + +def test_no_query_can_correlate_installs_through_user_hash(): + """`user_hash` is salted per install, so a global GROUP BY on it would be + inventing a cross-install identity the client deliberately refused to give.""" + for path in QUERIES.glob("*.sql"): + for sql in _statements(path): + assert "user_hash" not in sql + + +def test_sqlite_retention_deletes_what_postgres_would_drop(store, monkeypatch): + monkeypatch.setenv("TELEMETRY_RETENTION_DAYS", "30") + old = datetime.now(timezone.utc) - timedelta(days=200) + asyncio.run( + store.execute( + "INSERT INTO events (id, install_id, ts, received_at, name, props)" + " VALUES ($1, $2, $3, $4, $5, $6)", + uuid.uuid4().hex, + uuid.uuid4().hex, + old, + old, + "heartbeat", + "{}", + ) + ) + + assert ( + asyncio.run( + rollup.maintain_partitions(store, datetime.now(timezone.utc).date()) + ) + == [] + ) + assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 0 + + +def test_partition_names_run_a_month_ahead(): + """The Postgres path builds names from dates it computed, never from input.""" + assert rollup._next_month(rollup._month_start(datetime(2026, 12, 31).date())) == ( + datetime(2027, 1, 1).date() + ) + + +# ── Packaging ──────────────────────────────────────────────────────────── + + +def test_asyncpg_is_an_extra_and_never_a_runtime_dependency(): + """`uv sync` without the extra must not pull a database driver into a bot.""" + manifest = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8")) + project = manifest["project"] + + assert not any("asyncpg" in dep for dep in project["dependencies"]) + extras = project["optional-dependencies"] + assert any("asyncpg" in dep for dep in extras["telemetry-server"]) From 34e4ad5a94e2e862156324541f7b003e11cb5ff7 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 00:31:56 +0300 Subject: [PATCH 019/116] Move the telemetry collector to its own repo (condor-telemetry-server) The collector only ever imported the client's wire contract (condor/telemetry/schema.py); nothing in Condor imported the collector. It now lives in its own project with a vendored copy of that schema, so a bot install no longer carries a database driver or the server code. A guard test keeps asyncpg out of every dependency group and blocks any Condor module from importing telemetry_server again. --- pyproject.toml | 3 - telemetry_server/.env.example | 4 - telemetry_server/Dockerfile | 42 - telemetry_server/README.md | 183 ----- telemetry_server/__init__.py | 22 - telemetry_server/app.py | 90 --- telemetry_server/config.py | 49 -- telemetry_server/docker-compose.yml | 75 -- telemetry_server/grafana/datasource.yml | 27 - telemetry_server/ingest.py | 349 -------- telemetry_server/migrations/001_init.sql | 85 -- .../migrations/001_init.sqlite.sql | 59 -- telemetry_server/queries/adoption.sql | 19 - telemetry_server/queries/agent_economics.sql | 27 - telemetry_server/queries/feature_usage.sql | 25 - telemetry_server/queries/reliability.sql | 30 - telemetry_server/queries/retention.sql | 21 - telemetry_server/rollup.py | 339 -------- telemetry_server/store.py | 379 --------- tests/test_collector_is_extracted.py | 36 + tests/test_telemetry_server.py | 749 ------------------ 21 files changed, 36 insertions(+), 2577 deletions(-) delete mode 100644 telemetry_server/.env.example delete mode 100644 telemetry_server/Dockerfile delete mode 100644 telemetry_server/README.md delete mode 100644 telemetry_server/__init__.py delete mode 100644 telemetry_server/app.py delete mode 100644 telemetry_server/config.py delete mode 100644 telemetry_server/docker-compose.yml delete mode 100644 telemetry_server/grafana/datasource.yml delete mode 100644 telemetry_server/ingest.py delete mode 100644 telemetry_server/migrations/001_init.sql delete mode 100644 telemetry_server/migrations/001_init.sqlite.sql delete mode 100644 telemetry_server/queries/adoption.sql delete mode 100644 telemetry_server/queries/agent_economics.sql delete mode 100644 telemetry_server/queries/feature_usage.sql delete mode 100644 telemetry_server/queries/reliability.sql delete mode 100644 telemetry_server/queries/retention.sql delete mode 100644 telemetry_server/rollup.py delete mode 100644 telemetry_server/store.py create mode 100644 tests/test_collector_is_extracted.py delete mode 100644 tests/test_telemetry_server.py diff --git a/pyproject.toml b/pyproject.toml index 5c90f957..70d7398c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,6 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-asyncio", "black", "isort", "pre-commit"] -# The telemetry collector (FEAT-024, telemetry_server/). Kept out of a normal -# install: a bot that only *emits* telemetry has no use for a database driver. -telemetry-server = ["asyncpg"] [tool.isort] profile = "black" diff --git a/telemetry_server/.env.example b/telemetry_server/.env.example deleted file mode 100644 index 44d6900d..00000000 --- a/telemetry_server/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# Copy to .env beside docker-compose.yml. Both are required; compose refuses to -# start without them rather than defaulting to something guessable. -POSTGRES_PASSWORD=change-me -GRAFANA_PASSWORD=change-me diff --git a/telemetry_server/Dockerfile b/telemetry_server/Dockerfile deleted file mode 100644 index fa16ac1f..00000000 --- a/telemetry_server/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# The collector image. -# -# It builds from the Condor repository root rather than from this directory, -# because ingest.py imports `condor.telemetry.schema` — the same taxonomy the -# client sanitises with — and that import is the whole reason the collector -# lives in this repo (FEAT-024). The cost is an image that carries Condor's -# dependency set rather than four packages; the benefit is that the emitter and -# the validator cannot drift apart. That trade was made deliberately. -# -# Build from the repo root: docker build -f telemetry_server/Dockerfile . - -FROM python:3.12-slim - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -WORKDIR /app - -# Dependencies first so a code change does not re-resolve the world. -COPY pyproject.toml uv.lock* README.md ./ -RUN uv sync --extra telemetry-server --no-dev --no-install-project - -COPY condor/ ./condor/ -COPY telemetry_server/ ./telemetry_server/ -COPY utils/ ./utils/ -COPY config_manager.py ./ - -# Nothing here needs to be root, and an unauthenticated public endpoint is the -# last process that should be. -RUN useradd --system --uid 10001 collector && chown -R collector /app -USER collector - -EXPOSE 8000 - -# Plain HTTP on purpose: TLS terminates at the reverse proxy in front, matching -# how the hummingbot/deploy stack is already run. -CMD ["uv", "run", "--no-sync", "uvicorn", "telemetry_server.app:create_app", \ - "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/telemetry_server/README.md b/telemetry_server/README.md deleted file mode 100644 index e935687b..00000000 --- a/telemetry_server/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# Condor telemetry collector - -The receiving end of [`condor/telemetry/`](../condor/telemetry/) (FEAT-023). -Consenting installs POST batched, anonymous envelopes here; this service -validates them against the client's own taxonomy, stores them idempotently, -rolls them up nightly, and leaves five SQL files behind that answer the -questions the exercise exists for. - -It is not installed by default and it is not running anywhere. Standing it up is -a deliberate act. - -## Why it lives in this repository - -`ingest.py` imports `condor.telemetry.schema` — the *same module* the emitter -sanitises with. A separate collector repo would be tidier operationally but -would force the event taxonomy to exist in two places, and a taxonomy that -drifts between emitter and validator is the classic failure of this kind of -system: the client starts sending a property, the server silently drops it, and -nobody notices for three months. Here, drift is not unlikely — it is impossible, -and `tests/test_telemetry_server.py` asserts there is no second copy in the repo. - -The cost is a container image carrying Condor's dependency set rather than four -packages. That trade was made knowingly. - -## Endpoints - -| Method | Path | Behaviour | -|---|---|---| -| `POST` | `/v1/events` | Ingest one envelope → `202 {"accepted": n, "rejected": m, "duplicates": d}` | -| `GET` | `/health` | Liveness plus a real database round trip | - -There is no endpoint that reads data back to an install, and no API docs are -served: a schema browser on an unauthenticated endpoint is surface with no -reader. - -## Surviving the open internet - -Installs are anonymous by design, so there is no credential to issue without -creating the identity we explicitly do not want. The endpoint is therefore -public and unauthenticated, and the pipeline is ordered so that everything cheap -happens before anything expensive: - -1. **Body cap — 1 MB.** Counted as the stream arrives. `Content-Length` is - checked first because it is free, but it is not trusted: a chunked request - can omit or understate it. -2. **Rate limit by source IP** — token bucket, 60/h, *before* the JSON parser - runs. `X-Forwarded-For` is ignored unless `TELEMETRY_TRUSTED_PROXY` is set, - because otherwise the header is a one-line rate-limit bypass. The limiter's - key space is capped and evicts LRU, so an attacker-chosen `install_id` cannot - become a memory-exhaustion primitive. -3. **Envelope validation** — unknown `schema` versions, unknown `level`s, a - malformed `install_id` and batches over 500 events are refused whole. -4. **Event validation** against `condor.telemetry.schema`. An unknown event name - or an out-of-spec property is **dropped and counted**, and the rest of the - batch is still accepted — rejecting a whole envelope over one malformed event - would let a single client bug erase a day of otherwise good data. The - `rejected` counter is the early warning that a taxonomy change broke - something, so chart it. -5. **Persist** in one transaction, `ON CONFLICT DO NOTHING`. - -Two invariants hold throughout. **No payload value is ever formatted into SQL** — -`store.py` takes bound parameters only, and the only interpolation anywhere is -partition names built from dates the rollup computed itself. **No error response -echoes any part of the request** — every refusal is a fixed string, so this -endpoint cannot be turned into a reflector or used as an oracle. - -Unknown fields inside `app` and `config` are not stored, not logged, and not -reflected. They simply do not exist here. - -### What this does *not* defend against - -Anyone can POST fabricated envelopes with random `install_id`s and skew the -adoption numbers. Rate limits raise the cost; the real defence is that the payoff -is nil and the data steers internal direction, not anything with money attached. -If it ever becomes a problem the fix is a signed `install_id` issued on first -contact — deliberately not built now. - -## Storage - -`migrations/001_init.sql` (Postgres) and `001_init.sqlite.sql` (SQLite) define -the same four tables: - -- **`installs`** — one upserted row per install: identity, platform, capability - flags, counts, and a running `dropped_total`. -- **`events`** — append-only, partitioned by month in Postgres so the 90-day - retention is a `DROP TABLE` rather than a mass `DELETE`. -- **`install_days`** — written on the **ingest path**, from newly inserted rows - only. This is what every retention question depends on and what survives raw - expiry, which is why it is not deferred to the rollup. -- **`daily_metrics`** — the nightly rollup. Permanent. - -Both `ts` and `received_at` are kept. Client clocks on self-hosted boxes drift -and occasionally lie, so ingest clamps `ts` to `received_at ± 48 h`; dashboards -count volume by `received_at` and trust `ts` only for ordering inside one -install's session. - -**SQLite is a real backend, not a mock.** The collector's tests run in Condor's -own `uv run pytest` with no database daemon, against the same statements -production executes. Postgres is what you deploy; SQLite is what keeps the tests -honest and runnable. - -## Rollups - -`rollup.py` runs on an interval inside the collector process (a plain asyncio -loop — the job is "every few hours, never concurrently with itself", and one -service with no extra runtime is easier to reason about than one with a -scheduler in it). SQL does the grouping; Python does the date arithmetic and -cohort matching, which is what lets one rollup run unchanged against both -backends. - -It writes `dau`/`wau`/`mau`, `cohort_size` + `retention_d1/d7/d30`, -`version_share`/`os_share`/`provider_share`, event and command/action/routine -ranks, `error_rate` and `error_group`, the agent mix and per-turn cost, and -`dropped_total`/`dropped_rate` — so client-side rate limiting never masquerades -as "things got quieter". It also creates next month's partition and drops what -has aged past `TELEMETRY_RETENTION_DAYS`. - -Rollups exist from day one rather than "later" because backfilling one after -dropping the raw data it came from is impossible. - -## The five questions - -One file each in `queries/`, each runnable standalone against Postgres *or* the -SQLite the tests seed, and each readable without Grafana: - -| File | Question | -|---|---| -| `adoption.sql` | How many installs are alive, on what, and how fast do upgrades spread? | -| `retention.sql` | Do people stay? D1 / D7 / D30 by cohort. | -| `feature_usage.sql` | What is actually used, and what are we maintaining for nobody? | -| `reliability.sql` | What is broken out there, and against which upstream? | -| `agent_economics.sql` | Which providers and models, at what cost per turn, dry-run vs live? | - -No query groups by `user_hash`. It is salted per install, so it can be counted -inside one install but is meaningless across them, and grouping by it globally -would invent a cross-install identity the client deliberately refused to -provide. A test asserts this. - -## Configuration - -| Variable | Default | Meaning | -|---|---|---| -| `TELEMETRY_DSN` | `sqlite:///telemetry.db` | Postgres URL in production; anything else is a SQLite path. | -| `TELEMETRY_TRUSTED_PROXY` | unset | Believe `X-Forwarded-For`. Only set this when a proxy really is in front. | -| `TELEMETRY_RETENTION_DAYS` | `90` | How long raw events live. Rollups are permanent regardless. | -| `TELEMETRY_ROLLUP_INTERVAL_S` | `21600` | How often the rollup runs. | - -## Running it - -```bash -cp .env.example .env # set POSTGRES_PASSWORD and GRAFANA_PASSWORD -docker compose -f telemetry_server/docker-compose.yml up -d -``` - -Only `collector` should be reachable from the internet, and only through a -reverse proxy that terminates TLS — the service speaks plain HTTP inside the -compose network, matching how the `hummingbot/deploy` stack is already run. -Postgres and Grafana bind to loopback. **Grafana is the weakest operational link -here**: it is the only component with a login and a session cookie, so keep it -behind the same proxy or VPN as the rest, never exposed alongside ingest. - -`grafana/datasource.yml` provisions the Postgres datasource. Dashboards are not -auto-provisioned — the `queries/*.sql` files are the deliverable and a panel is -a paste. - -Once it is up, point an install at it: - -```bash -CONDOR_TELEMETRY_URL=https://telemetry.example.org/v1/events -``` - -## Tests - -```bash -uv run pytest tests/test_telemetry_server.py -``` - -They run against SQLite in a `tmp_path` and never bind a port, start a -container, or leave anything behind. The first test is the important one: it -builds its envelope by running the **real emitter** from `condor/telemetry/` and -handing the result to `context.envelope()`, so if FEAT-023 ever changes shape, -this suite fails instead of the collector silently dropping a field in -production. diff --git a/telemetry_server/__init__.py b/telemetry_server/__init__.py deleted file mode 100644 index 0d8e4ace..00000000 --- a/telemetry_server/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The receiving end of Condor's usage telemetry (FEAT-024). - -``condor/telemetry/`` (FEAT-023) makes every consenting install emit a batched, -anonymous event stream. This package is what it reports to: a small FastAPI -service that accepts those envelopes, stores them idempotently, rolls them up, -and leaves five SQL files behind that answer the questions the exercise exists -for. - -It lives in this repository on purpose. The alternative — a separate collector -repo — forces the event taxonomy to exist in two places, and a taxonomy that -drifts between emitter and validator is the classic failure of this kind of -system: the client starts sending a property, the server silently drops it, and -nobody notices for three months. :mod:`telemetry_server.ingest` imports -:mod:`condor.telemetry.schema` **directly**, so drift is not unlikely, it is -impossible. - -Nothing here is installed by default. ``asyncpg`` sits behind the -``telemetry-server`` extra in ``pyproject.toml`` and is imported lazily, so a -normal Condor install carries this directory as dead weight and nothing else. -""" - -__all__ = ["__doc__"] diff --git a/telemetry_server/app.py b/telemetry_server/app.py deleted file mode 100644 index 39fa2a0b..00000000 --- a/telemetry_server/app.py +++ /dev/null @@ -1,90 +0,0 @@ -"""The collector service: two endpoints and a background rollup. - -``create_app()`` mirrors ``condor/web/app.py`` — a factory returning a -configured :class:`~fastapi.FastAPI`, so the test suite builds an app against a -throwaway database instead of importing a module-level singleton that has -already opened a connection to whatever the environment pointed at. - -There is deliberately no read endpoint. Nothing here serves data back to an -install, so the only thing this process will do for an anonymous caller is -accept an envelope and say how much of it it kept. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi.responses import JSONResponse - -from telemetry_server import config, ingest, rollup -from telemetry_server.store import Store, open_store - -log = logging.getLogger(__name__) - - -async def _rollup_loop(app: FastAPI) -> None: - """Recompute the permanent tables on a fixed interval. - - A plain asyncio task rather than a scheduler dependency: the job is "run - this every few hours, never concurrently with itself", which is a loop, and - one service with no extra runtime is easier to reason about than one with a - scheduler in it. - """ - interval = config.rollup_interval_s() - while True: - try: - await asyncio.sleep(interval) - await rollup.run(app.state.store) - except asyncio.CancelledError: - raise - except Exception: - log.exception("Telemetry rollup failed; will retry next interval") - - -@asynccontextmanager -async def lifespan(app: FastAPI): - if getattr(app.state, "store", None) is None: - app.state.store = await open_store(config.dsn()) - app.state.owns_store = True - task = asyncio.create_task(_rollup_loop(app)) - try: - yield - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - if getattr(app.state, "owns_store", False): - await app.state.store.close() - - -def create_app(store: Store | None = None) -> FastAPI: - """Build the collector. Pass a ``store`` to drive it against a temp database.""" - app = FastAPI( - title="Condor Telemetry Collector", - version="1.0.0", - lifespan=lifespan, - # The ingest contract is documented in this repository; a public schema - # browser on an unauthenticated endpoint is surface with no reader. - docs_url=None, - redoc_url=None, - openapi_url=None, - ) - app.state.store = store - app.state.owns_store = False - app.include_router(ingest.router) - - @app.get("/health") - async def health() -> JSONResponse: - """Liveness plus a real database round trip.""" - try: - await app.state.store.ping() - except Exception: - log.exception("Telemetry collector health check failed") - return JSONResponse(status_code=503, content={"status": "degraded"}) - return JSONResponse(status_code=200, content={"status": "ok"}) - - return app diff --git a/telemetry_server/config.py b/telemetry_server/config.py deleted file mode 100644 index baa8c212..00000000 --- a/telemetry_server/config.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Every knob the collector reads from its environment, in one place. - -The house pattern (``utils/config.py``) is a module of module-level reads rather -than env lookups scattered through the code, so that what a deployment can -change is one file long. These are read per call rather than at import, because -the test suite sets them with ``monkeypatch.setenv``. -""" - -from __future__ import annotations - -import os - -DEFAULT_DSN = "sqlite:///telemetry.db" -DEFAULT_RETENTION_DAYS = 90 -DEFAULT_ROLLUP_INTERVAL_S = 6 * 3600 - - -def dsn() -> str: - """Where events go. A Postgres URL in production, a SQLite path otherwise.""" - return os.environ.get("TELEMETRY_DSN", "").strip() or DEFAULT_DSN - - -def trust_proxy() -> bool: - """Whether ``X-Forwarded-For`` may be believed. - - Off unless an operator says otherwise, because the header is written by the - caller: trusting it on a directly-exposed service hands every client a - one-header rate-limit bypass. - """ - return os.environ.get("TELEMETRY_TRUSTED_PROXY", "").strip().lower() in ( - "1", - "true", - "yes", - ) - - -def retention_days() -> int: - """How long raw events live. Rollups are permanent regardless.""" - try: - return max(1, int(os.environ.get("TELEMETRY_RETENTION_DAYS", "").strip())) - except ValueError: - return DEFAULT_RETENTION_DAYS - - -def rollup_interval_s() -> int: - try: - return max(60, int(os.environ.get("TELEMETRY_ROLLUP_INTERVAL_S", "").strip())) - except ValueError: - return DEFAULT_ROLLUP_INTERVAL_S diff --git a/telemetry_server/docker-compose.yml b/telemetry_server/docker-compose.yml deleted file mode 100644 index 4f09a9a1..00000000 --- a/telemetry_server/docker-compose.yml +++ /dev/null @@ -1,75 +0,0 @@ -# The collector stack: ingest, its database, and the dashboards. -# -# Only `collector` is meant to be reachable from the internet, and only through -# a reverse proxy that terminates TLS. Postgres and Grafana are bound to -# loopback: Grafana is the one component here with a login and a session cookie, -# and it has no business sharing an exposure with an endpoint whose whole design -# is that anyone may POST to it. -# -# docker compose -f telemetry_server/docker-compose.yml up -d -# -# Set POSTGRES_PASSWORD and GRAFANA_PASSWORD in an .env file beside this one. - -services: - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_DB: telemetry - POSTGRES_USER: telemetry - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} - volumes: - - telemetry-db:/var/lib/postgresql/data - ports: - - "127.0.0.1:5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U telemetry -d telemetry"] - interval: 10s - timeout: 5s - retries: 5 - - collector: - build: - context: .. - dockerfile: telemetry_server/Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - environment: - TELEMETRY_DSN: postgresql://telemetry:${POSTGRES_PASSWORD}@postgres:5432/telemetry - # X-Forwarded-For is only believed because there really is a proxy in - # front here. Leave this unset on a directly-exposed deployment. - TELEMETRY_TRUSTED_PROXY: "true" - TELEMETRY_RETENTION_DAYS: "90" - ports: - - "127.0.0.1:8000:8000" - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] - interval: 30s - timeout: 5s - retries: 3 - - grafana: - image: grafana/grafana:11.3.0 - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - environment: - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:?set GRAFANA_PASSWORD} - GF_USERS_ALLOW_SIGN_UP: "false" - GF_AUTH_ANONYMOUS_ENABLED: "false" - TELEMETRY_DB_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - grafana-data:/var/lib/grafana - - ./grafana:/etc/grafana/provisioning/datasources:ro - # The five queries, readable inside the container so a panel can be built - # by pasting one in. They are the deliverable; the panels are decoration. - - ./queries:/etc/telemetry-queries:ro - ports: - - "127.0.0.1:3000:3000" - -volumes: - telemetry-db: - grafana-data: diff --git a/telemetry_server/grafana/datasource.yml b/telemetry_server/grafana/datasource.yml deleted file mode 100644 index abadd044..00000000 --- a/telemetry_server/grafana/datasource.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Grafana reads the collector's Postgres directly. There is no API in between: -# the queries in ../queries/ are plain SQL against the rollup tables, so a panel -# is a paste rather than an integration. -# -# The account is read-only by intent. Grant it explicitly after first boot: -# CREATE USER grafana WITH PASSWORD '...'; -# GRANT CONNECT ON DATABASE telemetry TO grafana; -# GRANT USAGE ON SCHEMA public TO grafana; -# GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana; - -apiVersion: 1 - -datasources: - - name: Telemetry - type: postgres - uid: condor-telemetry - access: proxy - url: postgres:5432 - database: telemetry - user: telemetry - secureJsonData: - password: ${TELEMETRY_DB_PASSWORD} - jsonData: - sslmode: disable - postgresVersion: 1600 - isDefault: true - editable: false diff --git a/telemetry_server/ingest.py b/telemetry_server/ingest.py deleted file mode 100644 index 46937865..00000000 --- a/telemetry_server/ingest.py +++ /dev/null @@ -1,349 +0,0 @@ -"""``POST /v1/events`` — the one public, unauthenticated door. - -Installs are anonymous by design, so there is no credential to check: anything -on the internet can POST here. The pipeline below is ordered so that everything -cheap happens before anything expensive, and so that a hostile body is refused -before it can cost a parse, a memory allocation, or a database round trip. - -1. **Body cap.** 1 MB, enforced while streaming. An oversized body is dropped - without ever being fully buffered or parsed. -2. **Rate limit by source IP.** Token bucket, before the JSON parser runs. -3. **Parse and validate the envelope.** Unknown ``schema`` versions and more - than :data:`MAX_EVENTS` events are refused whole. -4. **Rate limit by ``install_id``**, now that we know it — still before any - database contact. -5. **Validate each event** against :mod:`condor.telemetry.schema`, the same - module the client sanitises with. Offenders are dropped and counted; the - rest of the batch is still accepted, because rejecting a whole envelope over - one malformed event would let a single client bug erase a day of otherwise - good data. -6. **Persist** in one transaction, with ``ON CONFLICT DO NOTHING``. - -Two rules hold everywhere in this module. Nothing from a payload is ever -formatted into SQL — :mod:`telemetry_server.store` takes parameters only. And -no error response ever echoes any part of the request: the replies are fixed -strings, so this endpoint cannot be turned into a reflector or used to confirm -what the server did with a probe. -""" - -from __future__ import annotations - -import json -import logging -import time -import uuid -from collections import OrderedDict -from datetime import datetime, timedelta, timezone - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse - -from condor.telemetry import schema - -log = logging.getLogger(__name__) - -router = APIRouter() - -KNOWN_SCHEMAS = frozenset({1}) -LEVELS = frozenset({"ping", "usage"}) - -MAX_BODY_BYTES = 1024 * 1024 -MAX_EVENTS = 500 -MAX_PROPS = 64 -MAX_PROVIDERS = 10 -MAX_COUNT = 1_000_000 -MAX_DROPPED = 10_000_000 -# Client clocks on self-hosted boxes drift and occasionally lie. Anything -# further out than this is the clock, not the event. -CLOCK_SLACK = timedelta(hours=48) - -RATE_PER_HOUR = 60 - - -class RateLimiter: - """Token bucket per key, with a bounded key space. - - The bound matters as much as the rate. ``install_id`` is attacker-chosen, so - a limiter that kept one bucket per id it has ever seen would be a memory - exhaustion primitive wearing a safety hat. Keys are held in an LRU capped at - :data:`max_keys`; evicting the least recently used one costs an attacker a - fresh bucket, which is exactly what they would have had anyway. - """ - - def __init__(self, per_hour: int = RATE_PER_HOUR, max_keys: int = 20_000) -> None: - self.per_hour = float(per_hour) - self.max_keys = max_keys - self._buckets: OrderedDict[str, tuple[float, float]] = OrderedDict() - - def allow(self, key: str) -> bool: - now = time.monotonic() - tokens, last = self._buckets.pop(key, (self.per_hour, now)) - tokens = min(self.per_hour, tokens + (now - last) * self.per_hour / 3600.0) - allowed = tokens >= 1.0 - self._buckets[key] = (tokens - 1.0 if allowed else tokens, now) - while len(self._buckets) > self.max_keys: - self._buckets.popitem(last=False) - return allowed - - def reset(self) -> None: - self._buckets.clear() - - -by_ip = RateLimiter() -by_install = RateLimiter() - - -class Refused(Exception): - """A whole envelope is unusable. Carries a fixed, contentless reason.""" - - def __init__(self, status: int, reason: str) -> None: - super().__init__(reason) - self.status = status - self.reason = reason - - -def _refuse(status: int, reason: str) -> JSONResponse: - """Every failure reply is a constant. No request data crosses back out.""" - return JSONResponse(status_code=status, content={"error": reason}) - - -async def read_body(request: Request) -> bytes: - """Read at most :data:`MAX_BODY_BYTES`, refusing anything larger. - - The declared ``Content-Length`` is checked first because it is free, but it - is not trusted: a chunked request can omit or understate it, so the stream - is counted as it arrives and abandoned the moment it crosses the cap. - """ - declared = request.headers.get("content-length") - if declared is not None: - try: - if int(declared) > MAX_BODY_BYTES: - raise Refused(413, "body_too_large") - except ValueError: - raise Refused(400, "bad_request") from None - - chunks: list[bytes] = [] - size = 0 - async for chunk in request.stream(): - size += len(chunk) - if size > MAX_BODY_BYTES: - raise Refused(413, "body_too_large") - chunks.append(chunk) - return b"".join(chunks) - - -def client_ip(request: Request) -> str: - """The peer address, or the forwarded one only where a proxy is trusted. - - ``X-Forwarded-For`` is a client-supplied header. Honouring it by default - would hand every caller a free rate-limit bypass, so it is read only when - the operator has said there is a proxy in front (``TELEMETRY_TRUSTED_PROXY``). - """ - from telemetry_server.config import trust_proxy - - if trust_proxy(): - forwarded = request.headers.get("x-forwarded-for", "") - first = forwarded.split(",")[0].strip() - if first: - return first[:64] - return request.client.host if request.client else "unknown" - - -def _uuid_or_none(value: object) -> str | None: - if not isinstance(value, str) or len(value) > 45: - return None - try: - return str(uuid.UUID(value)) - except ValueError: - return None - - -def _ts_or_none(value: object) -> datetime | None: - if not isinstance(value, str) or len(value) > 40: - return None - try: - parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -def clamp_ts(ts: datetime, received_at: datetime) -> datetime: - """Pull a lying clock back inside the window we are willing to believe.""" - return max(received_at - CLOCK_SLACK, min(ts, received_at + CLOCK_SLACK)) - - -def _flag(value: object) -> bool | None: - return value if isinstance(value, bool) else None - - -def _count(value: object) -> int | None: - if isinstance(value, bool) or not isinstance(value, int): - return None - return max(0, min(MAX_COUNT, value)) - - -def _install_row(envelope: dict, install_id: str, received_at: datetime) -> dict: - """Flatten ``app`` and ``config`` into the columns we keep. - - Only declared keys are read. An unknown field in either sub-object is not - stored, not logged, and not reflected — it simply does not exist here. - """ - app = envelope.get("app") - app = app if isinstance(app, dict) else {} - config = envelope.get("config") - config = config if isinstance(config, dict) else {} - - providers = config.get("llm_providers") - if isinstance(providers, (list, tuple)): - clean = [schema.clean_str(p) for p in list(providers)[:MAX_PROVIDERS]] - providers = [p for p in clean if p] - else: - providers = [] - - return { - "install_id": install_id, - "received_at": received_at, - "level": envelope["level"], - "version": schema.clean_str(app.get("version")), - "branch": schema.clean_str(app.get("branch")), - "os": schema.clean_str(app.get("os")), - "arch": schema.clean_str(app.get("arch")), - "python": schema.clean_str(app.get("python")), - "in_docker": _flag(app.get("in_docker")), - "has_hb_api": _flag(config.get("has_hb_api")), - "has_gateway": _flag(config.get("has_gateway")), - "has_web": _flag(config.get("has_web")), - "user_count": _count(config.get("user_count")), - "server_count": _count(config.get("server_count")), - "agent_count": _count(config.get("agent_count")), - "llm_providers": providers, - } - - -def validate(envelope: object, received_at: datetime) -> tuple[dict, list[dict], int]: - """Turn an untrusted body into rows, or raise :class:`Refused`. - - Returns ``(install, events, rejected)``. ``rejected`` counts every offender - dropped — a whole event whose name we do not know, and each individual - property that fell outside its declared spec. Both are the early warning - that a taxonomy change broke something, so they are counted together and - reported back rather than swallowed. - """ - if not isinstance(envelope, dict): - raise Refused(400, "invalid_envelope") - if envelope.get("schema") not in KNOWN_SCHEMAS: - raise Refused(400, "unknown_schema") - - install_id = _uuid_or_none(envelope.get("install_id")) - if install_id is None: - raise Refused(400, "invalid_envelope") - if envelope.get("level") not in LEVELS: - raise Refused(400, "invalid_envelope") - - raw_events = envelope.get("events") - if not isinstance(raw_events, list): - raise Refused(400, "invalid_envelope") - if len(raw_events) > MAX_EVENTS: - raise Refused(413, "too_many_events") - - install = _install_row(envelope, install_id, received_at) - - events: list[dict] = [] - rejected = 0 - seen: set[str] = set() - for raw in raw_events: - if not isinstance(raw, dict): - rejected += 1 - continue - event_id = _uuid_or_none(raw.get("id")) - ts = _ts_or_none(raw.get("ts")) - name = raw.get("name") - props = raw.get("props") - if props is None: - props = {} - if ( - event_id is None - or ts is None - or not isinstance(name, str) - or not schema.is_known(name) - or not isinstance(props, dict) - or len(props) > MAX_PROPS - ): - rejected += 1 - continue - if event_id in seen: - # A duplicate inside one envelope: the database would swallow it, - # but counting it here keeps `accepted` equal to rows written. - rejected += 1 - continue - seen.add(event_id) - - clean = schema.sanitize(name, props) - if clean is None: - rejected += 1 - continue - rejected += len(props) - len(clean) - - events.append( - { - "id": event_id, - "install_id": install_id, - "ts": clamp_ts(ts, received_at), - "received_at": received_at, - "name": name, - "props": clean, - } - ) - - return install, events, rejected - - -def _dropped(envelope: dict) -> int: - value = envelope.get("dropped") - if isinstance(value, bool) or not isinstance(value, int): - return 0 - return max(0, min(MAX_DROPPED, value)) - - -@router.post("/v1/events") -async def ingest(request: Request) -> JSONResponse: - try: - body = await read_body(request) - except Refused as refusal: - return _refuse(refusal.status, refusal.reason) - - if not by_ip.allow(client_ip(request)): - return _refuse(429, "rate_limited") - - try: - envelope = json.loads(body) - except ValueError: - return _refuse(400, "invalid_json") - - try: - install, events, rejected = validate(envelope, _now()) - except Refused as refusal: - return _refuse(refusal.status, refusal.reason) - - if not by_install.allow(install["install_id"]): - return _refuse(429, "rate_limited") - - store = request.app.state.store - try: - stored, duplicates = await store.record(install, events, _dropped(envelope)) - except Exception: - # The reason a write failed is ours to read, never the caller's. - log.exception("Telemetry ingest could not persist an envelope") - return _refuse(503, "unavailable") - - return JSONResponse( - status_code=202, - content={"accepted": stored, "rejected": rejected, "duplicates": duplicates}, - ) - - -def _now() -> datetime: - return datetime.now(timezone.utc) diff --git a/telemetry_server/migrations/001_init.sql b/telemetry_server/migrations/001_init.sql deleted file mode 100644 index 6a00d091..00000000 --- a/telemetry_server/migrations/001_init.sql +++ /dev/null @@ -1,85 +0,0 @@ --- Collector schema (FEAT-024), Postgres flavour. Idempotent: safe to re-run on --- every boot, which is how telemetry_server.app applies it. --- --- `installs` is upserted on every envelope; `events` is append-only and --- partitioned by month so the 90-day retention is a DROP TABLE rather than a --- mass DELETE; `install_days` is written on the ingest path because it is what --- survives raw expiry and every retention question depends on it; --- `daily_metrics` is the nightly rollup and is permanent. - -CREATE TABLE IF NOT EXISTS installs ( - install_id uuid PRIMARY KEY, - first_seen timestamptz NOT NULL, - last_seen timestamptz NOT NULL, - level text NOT NULL, - version text, - branch text, - os text, - arch text, - python text, - in_docker boolean, - -- Capability flags the client actually sends (condor/telemetry/context.py - -- `config_shape`). The original design omitted them; the wire has them. - has_hb_api boolean, - has_gateway boolean, - has_web boolean, - user_count integer, - server_count integer, - agent_count integer, - llm_providers text[], - dropped_total bigint NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS events ( - id uuid NOT NULL, - install_id uuid NOT NULL, - -- Both timestamps are kept on purpose. Client clocks on self-hosted boxes - -- drift and occasionally lie; ingest clamps `ts` to received_at +/- 48h, - -- dashboards count volume by `received_at`, and `ts` is only trusted for - -- ordering inside one install's session. - ts timestamptz NOT NULL, - received_at timestamptz NOT NULL DEFAULT now(), - name text NOT NULL, - props jsonb NOT NULL DEFAULT '{}', - PRIMARY KEY (id, ts) -) PARTITION BY RANGE (ts); - -CREATE INDEX IF NOT EXISTS events_name_ts ON events (name, ts); -CREATE INDEX IF NOT EXISTS events_install_ts ON events (install_id, ts); - -CREATE TABLE IF NOT EXISTS install_days ( - install_id uuid NOT NULL, - day date NOT NULL, - events bigint NOT NULL DEFAULT 0, - errors bigint NOT NULL DEFAULT 0, - version text, - PRIMARY KEY (install_id, day) -); - -CREATE TABLE IF NOT EXISTS daily_metrics ( - day date NOT NULL, - metric text NOT NULL, - dim text NOT NULL DEFAULT '', - value numeric NOT NULL, - PRIMARY KEY (day, metric, dim) -); - --- A partitioned table with no partition rejects every insert, so seed this --- month and next. telemetry_server.rollup keeps running a month ahead and drops --- what has aged out. -DO $$ -DECLARE - start_month date := date_trunc('month', now())::date; - bound date; -BEGIN - FOR i IN 0..1 LOOP - bound := (start_month + (i || ' month')::interval)::date; - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS %I PARTITION OF events ' - 'FOR VALUES FROM (%L) TO (%L)', - 'events_' || to_char(bound, 'YYYY_MM'), - bound, - (bound + interval '1 month')::date - ); - END LOOP; -END $$; diff --git a/telemetry_server/migrations/001_init.sqlite.sql b/telemetry_server/migrations/001_init.sqlite.sql deleted file mode 100644 index 39d72e42..00000000 --- a/telemetry_server/migrations/001_init.sqlite.sql +++ /dev/null @@ -1,59 +0,0 @@ --- The same schema in SQLite, for the test suite and a single-box trial. --- --- Three differences, all forced by the engine and none of them semantic: --- there is no partitioning (retention is a DELETE, which is fine at test --- volume), no array type (`llm_providers` is a JSON array in TEXT), and no --- native date/timestamp type (everything is ISO-8601 UTC text, which sorts and --- compares correctly). Column names, keys and conflict targets are identical to --- 001_init.sql so telemetry_server.store can share one set of statements. - -CREATE TABLE IF NOT EXISTS installs ( - install_id TEXT PRIMARY KEY, - first_seen TEXT NOT NULL, - last_seen TEXT NOT NULL, - level TEXT NOT NULL, - version TEXT, - branch TEXT, - os TEXT, - arch TEXT, - python TEXT, - in_docker INTEGER, - has_hb_api INTEGER, - has_gateway INTEGER, - has_web INTEGER, - user_count INTEGER, - server_count INTEGER, - agent_count INTEGER, - llm_providers TEXT, - dropped_total INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS events ( - id TEXT NOT NULL, - install_id TEXT NOT NULL, - ts TEXT NOT NULL, - received_at TEXT NOT NULL, - name TEXT NOT NULL, - props TEXT NOT NULL DEFAULT '{}', - PRIMARY KEY (id, ts) -); - -CREATE INDEX IF NOT EXISTS events_name_ts ON events (name, ts); -CREATE INDEX IF NOT EXISTS events_install_ts ON events (install_id, ts); - -CREATE TABLE IF NOT EXISTS install_days ( - install_id TEXT NOT NULL, - day TEXT NOT NULL, - events INTEGER NOT NULL DEFAULT 0, - errors INTEGER NOT NULL DEFAULT 0, - version TEXT, - PRIMARY KEY (install_id, day) -); - -CREATE TABLE IF NOT EXISTS daily_metrics ( - day TEXT NOT NULL, - metric TEXT NOT NULL, - dim TEXT NOT NULL DEFAULT '', - value REAL NOT NULL, - PRIMARY KEY (day, metric, dim) -); diff --git a/telemetry_server/queries/adoption.sql b/telemetry_server/queries/adoption.sql deleted file mode 100644 index d1fa5227..00000000 --- a/telemetry_server/queries/adoption.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Q1: How many installs are alive, on what, and how fast do upgrades spread? --- --- Reads `daily_metrics` only, so it stays correct after raw events expire and --- costs a few thousand rows rather than a full scan. Every query in this --- directory is plain ANSI: it runs against the collector's Postgres and against --- the SQLite the test suite seeds, and it is readable without Grafana. - -SELECT day, metric, dim, value -FROM daily_metrics -WHERE metric IN ('dau', 'wau', 'mau') -ORDER BY day, metric; - --- Version and OS mix, snapshotted once per rollup. The series of snapshots is --- what answers "how long does an upgrade take to propagate" — a version's share --- decaying across consecutive days is the propagation curve. -SELECT day, metric, dim AS value_of, value -FROM daily_metrics -WHERE metric IN ('version_share', 'os_share', 'provider_share') -ORDER BY day, metric, value DESC; diff --git a/telemetry_server/queries/agent_economics.sql b/telemetry_server/queries/agent_economics.sql deleted file mode 100644 index dbea2432..00000000 --- a/telemetry_server/queries/agent_economics.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Q5: How are agents actually used, and what does a turn cost? - -SELECT day, metric, dim AS value_of, value -FROM daily_metrics -WHERE metric IN ('agent_provider', 'model_rank', 'agent_kind') -ORDER BY metric, value DESC; - --- Outcome mix. A rising `aborted` share is a usability signal; a rising `error` --- share is a reliability one. -SELECT dim AS outcome, value AS turns -FROM daily_metrics -WHERE metric = 'agent_outcome' -ORDER BY value DESC; - --- What a turn costs, and what a routine costs. -SELECT day, metric, value -FROM daily_metrics -WHERE metric IN ('agent_tool_calls_avg', 'agent_duration_ms_avg', - 'routine_duration_ms_avg') -ORDER BY day, metric; - --- Dry run versus live, and how often a confirmation is actually granted. A deny --- or timeout rate that climbs means the agent is asking for the wrong things. -SELECT metric, dim AS value_of, value -FROM daily_metrics -WHERE metric IN ('strategy_mode', 'confirmation_decision') -ORDER BY metric, value DESC; diff --git a/telemetry_server/queries/feature_usage.sql b/telemetry_server/queries/feature_usage.sql deleted file mode 100644 index 48b99c47..00000000 --- a/telemetry_server/queries/feature_usage.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Q3: What is actually used, and what are we maintaining for nobody? --- --- Note what is absent: nothing groups by `user_hash`. It is salted per install --- (FEAT-023), so it can be counted inside one install but is meaningless --- across installs, and a query that grouped by it globally would be inventing --- a cross-install identity the client deliberately refused to provide. - -SELECT day, dim AS event_name, value AS events -FROM daily_metrics -WHERE metric = 'event_rank' -ORDER BY day, value DESC; - --- The key dimension behind each surface: which commands, which modules, which --- routines, which connectors. -SELECT day, metric, dim AS name, value AS uses -FROM daily_metrics -WHERE metric IN ('command_rank', 'action_rank', 'routine_rank', 'trade_rank') -ORDER BY metric, value DESC; - --- The activation funnel. `feature_first_use` fires once per install, ever, so --- this counts installs that ever reached a feature, not how often they use it. -SELECT dim AS feature, value AS installs_activated -FROM daily_metrics -WHERE metric = 'activation' -ORDER BY value DESC; diff --git a/telemetry_server/queries/reliability.sql b/telemetry_server/queries/reliability.sql deleted file mode 100644 index b263780d..00000000 --- a/telemetry_server/queries/reliability.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Q4: What is broken out there? --- --- `error_rate` is errors per event per install-day, so a single install in a --- crash loop shows up as a bad day rather than drowning the fleet average. - -SELECT day, value AS errors_per_event -FROM daily_metrics -WHERE metric = 'error_rate' -ORDER BY day; - --- Failure groups, keyed by exception type and the hash of the message. The --- message itself is never transmitted (FEAT-023) — grouping works off the hash. -SELECT dim AS exc_type_and_sig, value AS occurrences -FROM daily_metrics -WHERE metric = 'error_group' -ORDER BY value DESC; - --- Which upstream is failing: the Hummingbot API, Gateway, an LLM, or Telegram. -SELECT dim AS service, value AS failures -FROM daily_metrics -WHERE metric = 'upstream_error_rank' -ORDER BY value DESC; - --- Client-side rate limiting must never look like a quiet week. `dropped` is the --- emitter's own confession, and charting it next to volume is what keeps a --- taxonomy change or an error flood visible instead of silent. -SELECT day, metric, value -FROM daily_metrics -WHERE metric IN ('dropped_total', 'dropped_rate') -ORDER BY day; diff --git a/telemetry_server/queries/retention.sql b/telemetry_server/queries/retention.sql deleted file mode 100644 index f1dee5bb..00000000 --- a/telemetry_server/queries/retention.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Q2: Do people stay? D1 / D7 / D30 by first-seen cohort. --- --- `day` here is the cohort's first-seen day, not the day the metric describes. --- Cohorts are derived from `install_days`, which is written on the ingest path --- and never expires, so retention survives the 90-day raw retention window. - -SELECT - cohort.day AS cohort_day, - cohort.value AS installs, - d1.value AS retained_d1, - d7.value AS retained_d7, - d30.value AS retained_d30 -FROM daily_metrics AS cohort -LEFT JOIN daily_metrics AS d1 - ON d1.day = cohort.day AND d1.metric = 'retention_d1' -LEFT JOIN daily_metrics AS d7 - ON d7.day = cohort.day AND d7.metric = 'retention_d7' -LEFT JOIN daily_metrics AS d30 - ON d30.day = cohort.day AND d30.metric = 'retention_d30' -WHERE cohort.metric = 'cohort_size' -ORDER BY cohort.day; diff --git a/telemetry_server/rollup.py b/telemetry_server/rollup.py deleted file mode 100644 index 26dcb3f5..00000000 --- a/telemetry_server/rollup.py +++ /dev/null @@ -1,339 +0,0 @@ -"""The nightly job: small permanent tables, and partition housekeeping. - -Retention and DAU computed over raw events mean a full scan per dashboard -panel, and raw events expire at :func:`telemetry_server.config.retention_days` -anyway. So every question is answered from ``daily_metrics``, which this module -writes and which is never deleted. Backfilling a rollup after dropping the raw -data it came from is impossible, which is why this exists from day one rather -than "later". - -The aggregation is deliberately split: SQL does the grouping (cheap, indexed, -and identical in both dialects), Python does the date arithmetic and the -cohort matching. Date arithmetic is where the two dialects diverge most, and at -a few hundred installs the rows involved fit in a dict comfortably. The result -is one rollup that runs unchanged against Postgres and SQLite, so the numbers -the tests check are the numbers production computes. - -Partition maintenance is the one Postgres-only part, and it is a no-op -elsewhere. The table names it builds come from dates this module computed; no -payload value ever reaches a formatted statement. -""" - -from __future__ import annotations - -import json -import logging -from collections import defaultdict -from datetime import date, datetime, timedelta, timezone - -from telemetry_server import config -from telemetry_server.store import PostgresStore, Store - -log = logging.getLogger(__name__) - -TOP_N = 25 - - -def _as_date(value: object) -> date | None: - if isinstance(value, datetime): - return value.date() - if isinstance(value, date): - return value - if isinstance(value, str): - try: - return date.fromisoformat(value[:10]) - except ValueError: - return None - return None - - -async def run(store: Store, today: date | None = None) -> int: - """Recompute every metric. Returns how many rows were written.""" - today = today or datetime.now(timezone.utc).date() - written = 0 - written += await _activity(store) - written += await _retention(store) - written += await _shares(store, today) - written += await _ranks(store) - written += await _reliability(store, today) - written += await _agents(store, today) - await maintain_partitions(store, today) - return written - - -async def _activity(store: Store) -> int: - """DAU from ``install_days``; WAU and MAU by rolling the same rows.""" - rows = await store.fetch("SELECT install_id, day FROM install_days") - seen: dict[date, set] = defaultdict(set) - for install_id, day in rows: - parsed = _as_date(day) - if parsed is not None: - seen[parsed].add(install_id) - if not seen: - return 0 - - written = 0 - for day in sorted(seen): - for metric, window in (("dau", 1), ("wau", 7), ("mau", 30)): - active: set = set() - for offset in range(window): - active |= seen.get(day - timedelta(days=offset), set()) - await store.put_metric(day, metric, "", len(active)) - written += 1 - return written - - -async def _retention(store: Store) -> int: - """D1 / D7 / D30 from first-seen cohorts. - - The cohort day is ``MIN(day)`` in ``install_days`` rather than - ``installs.first_seen``, so retention keeps working for an install whose - row was upserted long after its first event. - """ - rows = await store.fetch("SELECT install_id, day FROM install_days") - days: dict[object, set] = defaultdict(set) - for install_id, day in rows: - parsed = _as_date(day) - if parsed is not None: - days[install_id].add(parsed) - if not days: - return 0 - - cohorts: dict[date, list] = defaultdict(list) - for install_id, active in days.items(): - cohorts[min(active)].append(install_id) - - written = 0 - for cohort_day, members in sorted(cohorts.items()): - await store.put_metric(cohort_day, "cohort_size", "", len(members)) - written += 1 - for horizon in (1, 7, 30): - target = cohort_day + timedelta(days=horizon) - retained = sum(1 for m in members if target in days[m]) - await store.put_metric(cohort_day, f"retention_d{horizon}", "", retained) - written += 1 - return written - - -async def _shares(store: Store, today: date) -> int: - """Version, OS and LLM-provider mix, as of the run. - - These describe the fleet now rather than a past day, so they are stamped - with the run date: a series of daily snapshots is exactly how "how long does - an upgrade take to propagate" gets answered. - """ - written = 0 - for metric, column in (("version_share", "version"), ("os_share", "os")): - rows = await store.fetch( - f"SELECT {column}, COUNT(*) FROM installs GROUP BY {column}" - ) - for value, count in rows: - await store.put_metric(today, metric, str(value or "unknown"), count) - written += 1 - - counts: dict[str, int] = defaultdict(int) - for (raw,) in await store.fetch("SELECT llm_providers FROM installs"): - for provider in _providers(raw): - counts[provider] += 1 - for provider, count in counts.items(): - await store.put_metric(today, "provider_share", provider, count) - written += 1 - return written - - -def _providers(raw: object) -> list[str]: - """One column, two shapes: a Postgres ``text[]`` or a SQLite JSON string.""" - if isinstance(raw, (list, tuple)): - return [str(p) for p in raw] - if isinstance(raw, str): - try: - parsed = json.loads(raw) - except ValueError: - return [] - return [str(p) for p in parsed] if isinstance(parsed, list) else [] - return [] - - -#: The one property of each event that carries the signal worth ranking. -RANK_DIMS = { - "command": ("command_rank", "name"), - "action": ("action_rank", "module"), - "routine_run": ("routine_rank", "routine"), - "agent_turn": ("model_rank", "model"), - "feature_first_use": ("activation", "feature"), - "trade": ("trade_rank", "connector"), -} - - -async def _ranks(store: Store) -> int: - """What is actually used: event volume per day, then the key dimensions.""" - written = 0 - rows = await store.fetch( - f"SELECT {store.DAY} AS d, name, COUNT(*) FROM events GROUP BY d, name" - ) - for day, name, count in rows: - parsed = _as_date(day) - if parsed is not None: - await store.put_metric(parsed, "event_rank", str(name), count) - written += 1 - - for event_name, (metric, prop) in RANK_DIMS.items(): - dim = store.json_get("props", prop) - rows = await store.fetch( - f"SELECT {dim} AS v, COUNT(*) AS n FROM events WHERE name = $1 " - f"GROUP BY v ORDER BY n DESC", - event_name, - ) - today = datetime.now(timezone.utc).date() - for value, count in rows[:TOP_N]: - if value is None: - continue - await store.put_metric(today, metric, str(value), count) - written += 1 - return written - - -async def _reliability(store: Store, today: date) -> int: - """Error rate per install-day, the top failure groups, and honest drops.""" - written = 0 - rows = await store.fetch( - "SELECT day, SUM(events), SUM(errors) FROM install_days GROUP BY day" - ) - for day, events, errors in rows: - parsed = _as_date(day) - if parsed is None or not events: - continue - await store.put_metric(parsed, "error_rate", "", (errors or 0) / events) - written += 1 - - exc = store.json_get("props", "exc_type") - sig = store.json_get("props", "sig") - groups = await store.fetch( - f"SELECT {exc} AS e, {sig} AS s, COUNT(*) AS n FROM events " - f"WHERE name = $1 GROUP BY e, s ORDER BY n DESC", - "error", - ) - for exc_type, signature, count in groups[:TOP_N]: - await store.put_metric( - today, - "error_group", - f"{exc_type or 'unknown'}:{signature or 'unknown'}", - count, - ) - written += 1 - - service = store.json_get("props", "service") - upstream = await store.fetch( - f"SELECT {service} AS s, COUNT(*) AS n FROM events WHERE name = $1 GROUP BY s", - "upstream_error", - ) - for name, count in upstream: - await store.put_metric( - today, "upstream_error_rank", str(name or "other"), count - ) - written += 1 - - # Client-side rate limiting must never masquerade as "things got quieter", - # so the confession the envelope carries is charted next to the volume. - (dropped,) = (await store.fetch("SELECT SUM(dropped_total) FROM installs"))[0] - (total,) = (await store.fetch("SELECT COUNT(*) FROM events"))[0] - await store.put_metric(today, "dropped_total", "", dropped or 0) - await store.put_metric( - today, - "dropped_rate", - "", - (dropped or 0) / (total + (dropped or 0)) if total else 0, - ) - return written + 2 - - -#: ``(event name, property, metric)`` triples whose distribution answers "how -#: are agents used" — provider and model mix, dry-run versus live, and how -#: often a confirmation is actually granted. -AGENT_DIMS = ( - ("agent_turn", "provider", "agent_provider"), - ("agent_turn", "outcome", "agent_outcome"), - ("agent_turn", "kind", "agent_kind"), - ("strategy_run", "mode", "strategy_mode"), - ("confirmation", "decision", "confirmation_decision"), -) - -#: Averages worth a number rather than a distribution. -AGENT_AVERAGES = ( - ("agent_turn", "tool_calls", "agent_tool_calls_avg"), - ("agent_turn", "duration_ms", "agent_duration_ms_avg"), - ("routine_run", "duration_ms", "routine_duration_ms_avg"), -) - - -async def _agents(store: Store, today: date) -> int: - """Agent economics: the mix, the cost per turn, and the trust decisions.""" - written = 0 - for event_name, prop, metric in AGENT_DIMS: - dim = store.json_get("props", prop) - rows = await store.fetch( - f"SELECT {dim} AS v, COUNT(*) FROM events WHERE name = $1 GROUP BY v", - event_name, - ) - for value, count in rows: - await store.put_metric(today, metric, str(value or "unknown"), count) - written += 1 - - for event_name, prop, metric in AGENT_AVERAGES: - column = store.json_num("props", prop) - rows = await store.fetch( - f"SELECT AVG({column}) FROM events WHERE name = $1", event_name - ) - average = rows[0][0] if rows else None - if average is not None: - await store.put_metric(today, metric, "", float(average)) - written += 1 - return written - - -def _month_start(day: date) -> date: - return day.replace(day=1) - - -def _next_month(day: date) -> date: - return (day.replace(day=28) + timedelta(days=4)).replace(day=1) - - -async def maintain_partitions(store: Store, today: date) -> list[str]: - """Create next month's partition, drop what has aged out. - - Returns the partition names it touched, which is what the tests assert on. - Postgres only: SQLite has no partitioning, and at the volume SQLite is used - for, ``DELETE`` is the right answer anyway. - """ - if not isinstance(store, PostgresStore): - cutoff = today - timedelta(days=config.retention_days()) - await store.execute("DELETE FROM events WHERE received_at < $1", cutoff) - return [] - - touched = [] - current = _month_start(today) - for start in (current, _next_month(current)): - end = _next_month(start) - name = f"events_{start:%Y_%m}" - await store.execute( - f"CREATE TABLE IF NOT EXISTS {name} PARTITION OF events " - f"FOR VALUES FROM ('{start:%Y-%m-%d}') TO ('{end:%Y-%m-%d}')" - ) - touched.append(name) - - cutoff = _month_start(today - timedelta(days=config.retention_days())) - existing = await store.fetch( - "SELECT c.relname FROM pg_class c " - "JOIN pg_inherits i ON i.inhrelid = c.oid " - "JOIN pg_class p ON p.oid = i.inhparent WHERE p.relname = 'events'" - ) - for (name,) in existing: - try: - start = datetime.strptime(name.removeprefix("events_"), "%Y_%m").date() - except ValueError: - continue - if start < cutoff: - await store.execute(f"DROP TABLE IF EXISTS {name}") - touched.append(f"-{name}") - return touched diff --git a/telemetry_server/store.py b/telemetry_server/store.py deleted file mode 100644 index 5efd8b2c..00000000 --- a/telemetry_server/store.py +++ /dev/null @@ -1,379 +0,0 @@ -"""Persistence for the collector — one interface, two backends. - -Production is Postgres (FEAT-024 chose it: known stack, ``ON CONFLICT`` gives -idempotency in one line, monthly partitions make retention a ``DROP TABLE``). -But a collector whose tests need a database daemon is a collector whose tests do -not run, so the same interface also speaks SQLite, which is what the suite in -``tests/test_telemetry_server.py`` drives. The two backends share their SQL: -every statement is written once with ``$n`` placeholders and a ``{jsonb}`` cast -hole, and each backend adapts only those two things. Sharing the statements is -the point — a second copy would drift exactly the way a second copy of the event -taxonomy would. - -Everything an envelope contributes is parameterised. No value from a payload is -ever formatted into a statement; the only string interpolation in this file is -the partition maintenance in :mod:`telemetry_server.rollup`, which builds names -from dates it computed itself. -""" - -from __future__ import annotations - -import json -import re -import sqlite3 -import threading -from datetime import date, datetime, timezone -from pathlib import Path - -MIGRATIONS = Path(__file__).parent / "migrations" - -_PLACEHOLDER = re.compile(r"\$(\d+)") - - -# ── The shared statements ──────────────────────────────────────────────── -# `$2` appears twice in the install upsert (first_seen and last_seen start -# equal); the SQLite adapter re-orders arguments by order of appearance, so a -# repeated placeholder is safe in both dialects. - -UPSERT_INSTALL = """ -INSERT INTO installs ( - install_id, first_seen, last_seen, level, version, branch, - os, arch, python, in_docker, has_hb_api, has_gateway, has_web, - user_count, server_count, agent_count, llm_providers, dropped_total) -VALUES ($1, $2, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, $16, $17) -ON CONFLICT (install_id) DO UPDATE SET - last_seen = excluded.last_seen, - level = excluded.level, - version = excluded.version, - branch = excluded.branch, - os = excluded.os, - arch = excluded.arch, - python = excluded.python, - in_docker = excluded.in_docker, - has_hb_api = excluded.has_hb_api, - has_gateway = excluded.has_gateway, - has_web = excluded.has_web, - user_count = excluded.user_count, - server_count = excluded.server_count, - agent_count = excluded.agent_count, - llm_providers = excluded.llm_providers, - dropped_total = installs.dropped_total + excluded.dropped_total -""" -# `first_seen` is deliberately absent from the DO UPDATE list: first contact is -# a fact about the past and a later envelope has no business moving it. - -INSERT_EVENT = """ -INSERT INTO events (id, install_id, ts, received_at, name, props) -VALUES ($1, $2, $3, $4, $5, $6{jsonb}) -ON CONFLICT DO NOTHING -""" - -UPSERT_INSTALL_DAY = """ -INSERT INTO install_days (install_id, day, events, errors, version) -VALUES ($1, $2, $3, $4, $5) -ON CONFLICT (install_id, day) DO UPDATE SET - events = install_days.events + excluded.events, - errors = install_days.errors + excluded.errors, - version = excluded.version -""" - -UPSERT_METRIC = """ -INSERT INTO daily_metrics (day, metric, dim, value) -VALUES ($1, $2, $3, $4) -ON CONFLICT (day, metric, dim) DO UPDATE SET value = excluded.value -""" - - -def _to_qmark(sql: str, args: tuple) -> tuple[str, list]: - """Rewrite ``$n`` placeholders as ``?`` and re-order args to match. - - Re-ordering by order of appearance is what makes a repeated ``$n`` legal in - the shared statements above. - """ - ordered: list = [] - - def swap(match: re.Match) -> str: - ordered.append(args[int(match.group(1)) - 1]) - return "?" - - return _PLACEHOLDER.sub(swap, sql), ordered - - -class Store: - """What ingest and rollup are allowed to ask of a database. - - Two SQL fragments differ between the dialects and cannot be parameterised — - casting a timestamp to a day, and reading one key out of a JSON column. - Subclasses expose them as :attr:`DAY` / :meth:`json_get` and - :mod:`telemetry_server.rollup` formats them into its aggregates. Every value - that reaches those aggregates is still a bound parameter; what is - interpolated is a constant chosen in this file and a key name chosen in - ``rollup.py``, never anything an envelope carried. - """ - - #: SQL expression turning ``events.received_at`` into a day. - DAY = "received_at::date" - - def json_get(self, column: str, key: str) -> str: - """SQL reading one top-level key of a JSON column as text.""" - return f"{column}->>'{key}'" - - def json_num(self, column: str, key: str) -> str: - """SQL reading one top-level key of a JSON column as a number.""" - return f"({column}->>'{key}')::numeric" - - async def migrate(self) -> None: - raise NotImplementedError - - async def ping(self) -> bool: - raise NotImplementedError - - async def close(self) -> None: - raise NotImplementedError - - async def record( - self, install: dict, events: list[dict], dropped: int - ) -> tuple[int, int]: - """Persist one validated envelope in a single transaction. - - Returns ``(stored, duplicates)``. ``install_days`` is incremented from - *newly inserted* rows only, so the client's retry-after-timeout path - cannot inflate a metric even though it legitimately re-sends the batch. - """ - raise NotImplementedError - - async def fetch(self, sql: str, *args) -> list[tuple]: - raise NotImplementedError - - async def execute(self, sql: str, *args) -> None: - raise NotImplementedError - - async def put_metric(self, day: date, metric: str, dim: str, value: float) -> None: - await self.execute(UPSERT_METRIC, day, metric, dim, float(value)) - - -def _day_buckets(events: list[dict]) -> dict: - """Group inserted events into ``(day) -> (count, errors)``.""" - buckets: dict[date, list[int]] = {} - for event in events: - day = event["ts"].astimezone(timezone.utc).date() - slot = buckets.setdefault(day, [0, 0]) - slot[0] += 1 - if event["name"] in ("error", "upstream_error"): - slot[1] += 1 - return buckets - - -class SqliteStore(Store): - """The test and small-deployment backend. - - ``sqlite3`` is synchronous, so every call holds a :class:`threading.Lock` - for the microseconds it takes — a thread lock rather than an asyncio one - precisely because there is no ``await`` inside the critical section, and - because an :class:`asyncio.Lock` binds itself to the first event loop that - touches it, which a store built in one loop and served from another would - trip over immediately. - """ - - DAY = "substr(received_at, 1, 10)" - - def json_get(self, column: str, key: str) -> str: - return f"json_extract({column}, '$.{key}')" - - def json_num(self, column: str, key: str) -> str: - # json_extract already yields SQLite's own numeric affinity for a JSON - # number, so unlike Postgres there is nothing to cast. - return f"json_extract({column}, '$.{key}')" - - def __init__(self, path: str) -> None: - self._path = path - self._lock = threading.Lock() - self._conn = sqlite3.connect(path, check_same_thread=False) - self._conn.execute("PRAGMA journal_mode=WAL") - self._conn.execute("PRAGMA foreign_keys=ON") - - def _run(self, sql: str, args: tuple) -> sqlite3.Cursor: - # Only the shared INSERT carries the cast hole; leave every other - # statement's text alone so a stray brace in a rollup aggregate can - # never be mistaken for a format field. - if "{jsonb}" in sql: - sql = sql.format(jsonb="") - statement, ordered = _to_qmark(sql, _as_text_dates(args)) - return self._conn.execute(statement, ordered) - - async def migrate(self) -> None: - with self._lock: - self._conn.executescript( - (MIGRATIONS / "001_init.sqlite.sql").read_text(encoding="utf-8") - ) - self._conn.commit() - - async def ping(self) -> bool: - with self._lock: - self._conn.execute("SELECT 1").fetchone() - return True - - async def close(self) -> None: - with self._lock: - self._conn.close() - - async def record( - self, install: dict, events: list[dict], dropped: int - ) -> tuple[int, int]: - with self._lock: - try: - self._run(UPSERT_INSTALL, _install_args(install, dropped, json.dumps)) - inserted: list[dict] = [] - for event in events: - cursor = self._run( - INSERT_EVENT, _event_args(event, str, json.dumps) - ) - if cursor.rowcount: - inserted.append(event) - for day, (count, errors) in _day_buckets(inserted).items(): - self._run( - UPSERT_INSTALL_DAY, - ( - install["install_id"], - day.isoformat(), - count, - errors, - install["version"], - ), - ) - self._conn.commit() - except Exception: - self._conn.rollback() - raise - return len(inserted), len(events) - len(inserted) - - async def fetch(self, sql: str, *args) -> list[tuple]: - with self._lock: - return list(self._run(sql, args).fetchall()) - - async def execute(self, sql: str, *args) -> None: - with self._lock: - self._run(sql, args) - self._conn.commit() - - -def _as_text_dates(args: tuple) -> tuple: - """SQLite has no date or timestamp type, and its implicit datetime adapter is - deprecated. Dates and timestamps go in as ISO-8601 text, matching the DDL and - sorting correctly.""" - return tuple(a.isoformat() if isinstance(a, (date, datetime)) else a for a in args) - - -class PostgresStore(Store): - """The production backend. ``asyncpg`` is imported here and nowhere else.""" - - def __init__(self, dsn: str) -> None: - self._dsn = dsn - self._pool = None - - async def connect(self) -> None: - import asyncpg # noqa: PLC0415 - behind the telemetry-server extra - - self._pool = await asyncpg.create_pool(self._dsn, min_size=1, max_size=8) - - async def migrate(self) -> None: - sql = (MIGRATIONS / "001_init.sql").read_text(encoding="utf-8") - async with self._pool.acquire() as conn: - await conn.execute(sql) - - async def ping(self) -> bool: - async with self._pool.acquire() as conn: - await conn.fetchval("SELECT 1") - return True - - async def close(self) -> None: - if self._pool is not None: - await self._pool.close() - - async def record( - self, install: dict, events: list[dict], dropped: int - ) -> tuple[int, int]: - import uuid as _uuid - - statement = INSERT_EVENT.format(jsonb="::jsonb") - async with self._pool.acquire() as conn: - async with conn.transaction(): - await conn.execute( - UPSERT_INSTALL, *_install_args(install, dropped, list) - ) - inserted = [] - for event in events: - status = await conn.execute( - statement, *_event_args(event, _uuid.UUID, json.dumps) - ) - # asyncpg returns "INSERT 0 1", or "INSERT 0 0" when the - # ON CONFLICT swallowed a retry. - if status.rsplit(" ", 1)[-1] != "0": - inserted.append(event) - for day, (count, errors) in _day_buckets(inserted).items(): - await conn.execute( - UPSERT_INSTALL_DAY, - _uuid.UUID(install["install_id"]), - day, - count, - errors, - install["version"], - ) - return len(inserted), len(events) - len(inserted) - - async def fetch(self, sql: str, *args) -> list[tuple]: - async with self._pool.acquire() as conn: - return [tuple(r) for r in await conn.fetch(sql, *args)] - - async def execute(self, sql: str, *args) -> None: - async with self._pool.acquire() as conn: - await conn.execute(sql, *args) - - -def _install_args(install: dict, dropped: int, providers) -> tuple: - return ( - install["install_id"], - install["received_at"], - install["level"], - install["version"], - install["branch"], - install["os"], - install["arch"], - install["python"], - install["in_docker"], - install["has_hb_api"], - install["has_gateway"], - install["has_web"], - install["user_count"], - install["server_count"], - install["agent_count"], - providers(install["llm_providers"]), - dropped, - ) - - -def _event_args(event: dict, ident, dump) -> tuple: - return ( - ident(event["id"]), - ident(event["install_id"]), - event["ts"], - event["received_at"], - event["name"], - dump(event["props"]), - ) - - -async def open_store(dsn: str) -> Store: - """Build the backend the DSN asks for, migrated and ready. - - Anything that is not a Postgres URL is a SQLite path, which is how the tests - and a single-box trial run get a collector without installing a daemon. - """ - if dsn.startswith(("postgres://", "postgresql://")): - store: Store = PostgresStore(dsn) - await store.connect() - else: - store = SqliteStore(dsn.removeprefix("sqlite:///").removeprefix("sqlite://")) - await store.migrate() - return store diff --git a/tests/test_collector_is_extracted.py b/tests/test_collector_is_extracted.py new file mode 100644 index 00000000..c4cb9b32 --- /dev/null +++ b/tests/test_collector_is_extracted.py @@ -0,0 +1,36 @@ +"""The telemetry collector lives in its own repo (``condor-telemetry-server``). + +Condor only *emits* telemetry; it must never pull the collector's database +driver into a bot install, and no Condor module may import the collector. These +guard the FEAT-024 extraction so the coupling cannot silently creep back and +Condor keeps running with the collector entirely absent. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + + +def test_asyncpg_is_never_a_condor_dependency(): + """A bot that only emits telemetry has no use for a database driver.""" + project = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + assert not any("asyncpg" in dep for dep in project.get("dependencies", [])) + for group, deps in project.get("optional-dependencies", {}).items(): + assert not any( + "asyncpg" in dep for dep in deps + ), f"asyncpg leaked into the {group!r} extra" + + +def test_no_condor_module_imports_the_collector(): + """The collector was extracted; nothing in the client tree may reach for it.""" + offenders = [ + path.relative_to(REPO).as_posix() + for path in (REPO / "condor").rglob("*.py") + if "telemetry_server" in path.read_text(encoding="utf-8") + ] + assert offenders == [], f"collector coupling crept back into: {offenders}" diff --git a/tests/test_telemetry_server.py b/tests/test_telemetry_server.py deleted file mode 100644 index 9031af93..00000000 --- a/tests/test_telemetry_server.py +++ /dev/null @@ -1,749 +0,0 @@ -"""The collector (FEAT-024) — the tests that make a public endpoint survivable. - -Three things are being proven here, in descending order of how much they matter. - -**The wire contract is the client's, not a hand-copy.** The first test builds -its envelope by running the real emitter from ``condor/telemetry/`` and handing -the result to ``context.envelope()`` — the same code path a live install uses. -If FEAT-023 ever changes shape, this file fails rather than the collector -silently dropping a field in production. - -**Untrusted input cannot do damage.** This endpoint is unauthenticated and -public by design, so most of what follows is negative: an oversized body, an -over-long batch, a rate-limited caller and a malformed envelope each have to be -refused *without touching the database*, and no refusal may echo any part of -the request back. - -**Idempotency is real.** The client retries after a timeout. If a retry could -inflate a count, every adoption number the exercise exists to produce would be -unreliable. - -The whole suite runs against SQLite in a ``tmp_path``. Nothing here binds a -port, starts a container, or outlives the test. -""" - -from __future__ import annotations - -import asyncio -import json -import re -import tomllib -import uuid -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import pytest -from starlette.testclient import TestClient - -from telemetry_server import ingest, rollup -from telemetry_server.app import create_app -from telemetry_server.store import open_store - -REPO = Path(__file__).resolve().parent.parent -QUERIES = REPO / "telemetry_server" / "queries" - - -# ── Harness ────────────────────────────────────────────────────────────── - - -@pytest.fixture(autouse=True) -def fresh_limits(): - """The limiters are process-wide; a leftover bucket would leak between tests.""" - ingest.by_ip.reset() - ingest.by_install.reset() - yield - ingest.by_ip.reset() - ingest.by_install.reset() - - -@pytest.fixture -def store(tmp_path): - store = asyncio.run(open_store(str(tmp_path / "telemetry.db"))) - yield store - asyncio.run(store.close()) - - -@pytest.fixture -def client(store): - with TestClient(create_app(store)) as client: - yield client - - -class Spy: - """A store that refuses to be used, so "no database contact" is checkable.""" - - def __init__(self): - self.calls = 0 - - async def record(self, *args, **kwargs): - self.calls += 1 - raise AssertionError("the database was reached for a request we refuse") - - async def ping(self): - return True - - async def close(self): - return None - - -@pytest.fixture -def spy_client(): - spy = Spy() - with TestClient(create_app(spy)) as client: - yield client, spy - - -def _event(name="command", props=None, ts=None, event_id=None): - return { - "id": event_id or uuid.uuid4().hex, - "ts": ts or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "name": name, - "props": ( - {"name": "portfolio", "surface": "telegram"} if props is None else props - ), - } - - -def _envelope(events=None, **overrides): - """The shape condor/telemetry/context.py actually puts on the wire.""" - envelope = { - "schema": 1, - "install_id": uuid.uuid4().hex, - "level": "usage", - "sent_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "app": { - "version": "54ad4dc", - "branch": "main", - "python": "3.12", - "os": "linux", - "arch": "arm64", - "in_docker": True, - }, - "config": { - "has_web": True, - "has_gateway": False, - "has_hb_api": True, - "llm_providers": ["openai", "openrouter"], - "user_count": 3, - "server_count": 2, - "agent_count": 4, - }, - "dropped": 0, - "events": [_event()] if events is None else events, - } - envelope.update(overrides) - return envelope - - -def _rows(store, sql, *args): - return asyncio.run(store.fetch(sql, *args)) - - -# ── The contract is the client's ───────────────────────────────────────── - - -def test_an_envelope_built_by_the_real_client_is_accepted_whole( - client, store, tmp_path, monkeypatch -): - """The acceptance criterion that matters: a genuine FEAT-023 envelope lands. - - Nothing here hand-writes the wire format. The events come out of the real - emitter and the envelope out of ``context.envelope``, so this test is the - thing that fails if emitter and collector ever drift apart. - """ - import config_manager as cm_module - from condor.telemetry import consent, context, emitter - - monkeypatch.chdir(tmp_path) - monkeypatch.setattr("utils.config.CONDOR_TELEMETRY", None, raising=False) - monkeypatch.setattr("utils.config.CONDOR_TELEMETRY_URL", None, raising=False) - monkeypatch.setattr( - "condor.agents.agent._DATA_ROOT", str(tmp_path / "data"), raising=False - ) - cm_module.ConfigManager.reset_instance() - emitter.discard_buffer() - emitter.set_hosted(True) - consent.refresh() - try: - cm_module.get_config_manager() - consent.grant("usage") - - emitter.emit("install") - emitter.emit("command", name="portfolio", surface="telegram", authorized=True) - emitter.emit("action", module="bots", verb="deploy", surface="web") - emitter.emit("trade", venue="cex", connector="binance", side="buy") - emitter.emit( - "agent_turn", kind="chat", provider="openai", model="gpt-5", tool_calls=3 - ) - emitter.emit("error", where="handlers.bots", exc_type="ValueError", sig="ab12") - - events, dropped = emitter.drain() - envelope = context.envelope(events, dropped, consent.level()) - finally: - cm_module.ConfigManager.reset_instance() - emitter.discard_buffer() - consent.refresh() - - response = client.post("/v1/events", json=envelope) - - assert response.status_code == 202 - assert response.json() == { - "accepted": len(events), - "rejected": 0, - "duplicates": 0, - } - - stored = _rows(store, "SELECT name FROM events ORDER BY name") - assert sorted(n for (n,) in stored) == sorted(e["name"] for e in events) - - # The install context lands as columns, including the capability flags the - # client sends but the original design's table did not have. - (row,) = _rows( - store, - "SELECT level, version, branch, os, in_docker, has_hb_api, has_web, " - "user_count, llm_providers FROM installs", - ) - assert row[0] == "usage" - assert row[1] == envelope["app"]["version"] - assert json.loads(row[8]) == envelope["config"]["llm_providers"] - - -def test_the_taxonomy_has_exactly_one_definition_in_the_repo(): - """``ingest`` validates against the emitter's own module, not a copy.""" - source = (REPO / "telemetry_server" / "ingest.py").read_text(encoding="utf-8") - assert "from condor.telemetry import schema" in source - - definitions = [] - for path in REPO.rglob("*.py"): - if any(part in {".venv", "node_modules", "__pycache__"} for part in path.parts): - continue - if re.search( - r"^EVENTS(\s*:\s*[^=]+)?\s*=", path.read_text(encoding="utf-8"), re.M - ): - definitions.append(path.relative_to(REPO).as_posix()) - - assert definitions == ["condor/telemetry/schema.py"] - - -# ── Idempotency and partial acceptance ─────────────────────────────────── - - -def test_the_same_envelope_twice_stores_one_copy(client, store): - """The client retries after a timeout; a retry must not inflate a metric.""" - envelope = _envelope([_event(), _event(), _event()]) - - first = client.post("/v1/events", json=envelope) - second = client.post("/v1/events", json=envelope) - - assert first.json()["accepted"] == 3 - assert second.json() == {"accepted": 0, "rejected": 0, "duplicates": 3} - - assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 3 - # install_days is incremented from newly inserted rows only, so the rollup - # tables cannot be inflated by a retry either. - assert _rows(store, "SELECT SUM(events) FROM install_days")[0][0] == 3 - - -def test_one_unknown_name_and_one_stray_prop_cost_two_rejections(client, store): - """Partial acceptance: a single client bug must not erase a good batch.""" - envelope = _envelope( - [ - _event(), - _event(name="a_command_from_a_newer_client"), - _event(props={"name": "bots", "surface": "web", "wallet": "0xdeadbeef"}), - ] - ) - - response = client.post("/v1/events", json=envelope) - - assert response.status_code == 202 - assert response.json()["rejected"] == 2 - assert response.json()["accepted"] == 2 - - (props,) = _rows(store, "SELECT props FROM events WHERE name = $1", "command")[1] - assert "wallet" not in props - assert "0xdeadbeef" not in props - - -def test_a_duplicate_id_inside_one_envelope_is_counted_once(client, store): - shared = uuid.uuid4().hex - envelope = _envelope([_event(event_id=shared), _event(event_id=shared)]) - - assert client.post("/v1/events", json=envelope).json() == { - "accepted": 1, - "rejected": 1, - "duplicates": 0, - } - assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 1 - - -def test_a_clock_from_next_year_is_clamped_not_stored(client, store): - """Self-hosted clocks drift and occasionally lie. Believe them within 48h.""" - future = (datetime.now(timezone.utc) + timedelta(days=365)).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - past = (datetime.now(timezone.utc) - timedelta(days=400)).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - client.post("/v1/events", json=_envelope([_event(ts=future), _event(ts=past)])) - - stored = [ - datetime.fromisoformat(ts) for (ts,) in _rows(store, "SELECT ts FROM events") - ] - now = datetime.now(timezone.utc) - for ts in stored: - assert abs(ts - now) <= ingest.CLOCK_SLACK + timedelta(minutes=1) - - -# ── Untrusted input ────────────────────────────────────────────────────── - - -def test_an_oversized_body_is_refused_without_a_query(spy_client): - client, spy = spy_client - body = b'{"schema":1,"pad":"' + b"a" * (ingest.MAX_BODY_BYTES + 1024) + b'"}' - - response = client.post( - "/v1/events", content=body, headers={"content-type": "application/json"} - ) - - assert response.status_code == 413 - assert response.json() == {"error": "body_too_large"} - assert spy.calls == 0 - - -def test_an_oversized_body_is_refused_even_without_a_content_length(): - """Content-Length is the caller's claim, so the stream is counted as it arrives.""" - - class Chunked: - headers: dict = {} - client = None - - async def stream(self): - for _ in range(4): - yield b"a" * (ingest.MAX_BODY_BYTES // 3) - - with pytest.raises(ingest.Refused) as refusal: - asyncio.run(ingest.read_body(Chunked())) - assert refusal.value.reason == "body_too_large" - - -def test_a_ten_thousand_event_envelope_is_refused_without_a_query(spy_client): - """Two caps guard this, and the outer one bites first. - - Ten thousand events cannot fit inside a 1 MB body, so such an envelope is - refused while it is still bytes on a socket — a stronger guarantee than the - event-count cap. The count cap is what catches the envelope that *does* fit, - which is asserted separately below. - """ - client, spy = spy_client - - response = client.post( - "/v1/events", json=_envelope([_event() for _ in range(10_000)]) - ) - - assert response.status_code == 413 - assert response.json()["error"] in ("body_too_large", "too_many_events") - assert spy.calls == 0 - - -def test_more_events_than_the_cap_are_refused_without_a_query(spy_client): - client, spy = spy_client - oversized = [_event() for _ in range(ingest.MAX_EVENTS + 1)] - - response = client.post("/v1/events", json=_envelope(oversized)) - - assert response.status_code == 413 - assert response.json() == {"error": "too_many_events"} - assert spy.calls == 0 - - -@pytest.mark.parametrize( - "envelope, reason", - [ - ( - { - "schema": 99, - "install_id": uuid.uuid4().hex, - "level": "usage", - "events": [], - }, - "unknown_schema", - ), - ( - {"schema": 1, "install_id": "not-a-uuid", "level": "usage", "events": []}, - "invalid_envelope", - ), - ( - { - "schema": 1, - "install_id": uuid.uuid4().hex, - "level": "root", - "events": [], - }, - "invalid_envelope", - ), - ( - { - "schema": 1, - "install_id": uuid.uuid4().hex, - "level": "usage", - "events": {}, - }, - "invalid_envelope", - ), - ([1, 2, 3], "invalid_envelope"), - ], -) -def test_a_malformed_envelope_is_refused_without_a_query(spy_client, envelope, reason): - client, spy = spy_client - - response = client.post("/v1/events", json=envelope) - - assert response.status_code == 400 - assert response.json() == {"error": reason} - assert spy.calls == 0 - - -def test_a_refusal_never_echoes_the_request(spy_client): - """No reflector, and no oracle for what the server did with a probe.""" - marker = "canary-9f13ab-do-not-reflect" - client, _ = spy_client - - responses = [ - client.post("/v1/events", content=marker.encode()), - client.post("/v1/events", json={"schema": 1, "install_id": marker}), - client.post("/v1/events", json=_envelope(install_id=marker)), - ] - - for response in responses: - assert response.status_code in (400, 413) - assert marker not in response.text - assert set(response.json()) == {"error"} - - -def test_body_that_is_not_json_is_refused(spy_client): - client, spy = spy_client - response = client.post("/v1/events", content=b"{not json at all") - assert response.status_code == 400 - assert response.json() == {"error": "invalid_json"} - assert spy.calls == 0 - - -def test_exceeding_the_ip_rate_limit_returns_429_without_a_query(spy_client): - """The limiter runs before the JSON parser, so a flood costs us nothing.""" - client, spy = spy_client - for _ in range(ingest.RATE_PER_HOUR): - ingest.by_ip.allow("testclient") - - response = client.post("/v1/events", json=_envelope()) - - assert response.status_code == 429 - assert response.json() == {"error": "rate_limited"} - assert spy.calls == 0 - - -def test_a_flood_from_one_install_is_limited_even_across_addresses(spy_client): - """The per-install bucket is the one an attacker cannot rotate away from.""" - client, spy = spy_client - install_id = str(uuid.uuid4()) - for _ in range(ingest.RATE_PER_HOUR): - ingest.by_install.allow(install_id) - - response = client.post("/v1/events", json=_envelope(install_id=install_id)) - - assert response.status_code == 429 - assert spy.calls == 0 - - -def test_the_limiter_key_space_is_bounded(spy_client): - """An attacker-chosen key must not be a memory exhaustion primitive.""" - limiter = ingest.RateLimiter(per_hour=60, max_keys=32) - for index in range(5_000): - limiter.allow(f"install-{index}") - assert len(limiter._buckets) <= 32 - - -def test_a_forwarded_header_is_ignored_unless_a_proxy_is_trusted(monkeypatch): - """Honouring X-Forwarded-For by default would be a one-header limit bypass.""" - - class Request: - headers = {"x-forwarded-for": "1.2.3.4"} - - class client: - host = "10.0.0.1" - - monkeypatch.delenv("TELEMETRY_TRUSTED_PROXY", raising=False) - assert ingest.client_ip(Request()) == "10.0.0.1" - - monkeypatch.setenv("TELEMETRY_TRUSTED_PROXY", "true") - assert ingest.client_ip(Request()) == "1.2.3.4" - - -def test_a_payload_cannot_reach_the_sql(client, store): - """Every value is a bound parameter; nothing is formatted into a statement.""" - injection = "1'); DROP TABLE events; --" - envelope = _envelope() - envelope["app"]["version"] = injection - envelope["config"]["llm_providers"] = [injection] - - assert client.post("/v1/events", json=envelope).status_code == 202 - - assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 1 - (version,) = _rows(store, "SELECT version FROM installs")[0] - assert "DROP TABLE" not in version - - -def test_unknown_envelope_fields_are_dropped_not_stored(client, store): - envelope = _envelope() - envelope["app"]["hostname"] = "trading-box.local" - envelope["config"]["api_key"] = "sk-live-secret" - envelope["surprise"] = {"nested": "value"} - - assert client.post("/v1/events", json=envelope).status_code == 202 - - dump = json.dumps(_rows(store, "SELECT * FROM installs")) - assert "trading-box" not in dump - assert "sk-live-secret" not in dump - - -def test_absurd_counts_are_clamped(client, store): - envelope = _envelope() - envelope["config"]["user_count"] = 10**18 - envelope["dropped"] = 10**18 - - client.post("/v1/events", json=envelope) - - (users, dropped) = _rows(store, "SELECT user_count, dropped_total FROM installs")[0] - assert users == ingest.MAX_COUNT - assert dropped == ingest.MAX_DROPPED - - -# ── Health ─────────────────────────────────────────────────────────────── - - -def test_health_reports_the_database(client): - assert client.get("/health").json() == {"status": "ok"} - - -def test_health_degrades_when_the_database_is_gone(): - class Broken: - async def ping(self): - raise RuntimeError("connection refused") - - async def close(self): - return None - - with TestClient(create_app(Broken())) as client: - response = client.get("/health") - assert response.status_code == 503 - assert response.json() == {"status": "degraded"} - - -# ── Rollups and queries ────────────────────────────────────────────────── - - -def _seed_cohorts(store): - """Three installs with hand-checkable retention, written straight to the - rollup table so the arithmetic under test is the rollup's, not ingest's.""" - base = datetime(2026, 7, 1, tzinfo=timezone.utc).date() - plan = { - "aaaaaaaa-0000-4000-8000-000000000001": [0, 1, 7], # retained at D1 and D7 - "aaaaaaaa-0000-4000-8000-000000000002": [0, 1], # D1 only - "aaaaaaaa-0000-4000-8000-000000000003": [0], # never came back - } - for install_id, offsets in plan.items(): - for offset in offsets: - asyncio.run( - store.execute( - "INSERT INTO install_days (install_id, day, events, errors, version)" - " VALUES ($1, $2, $3, $4, $5)", - install_id, - base + timedelta(days=offset), - 10, - 2 if offset == 0 else 0, - "54ad4dc", - ) - ) - return base - - -def test_retention_is_computed_from_the_permanent_table(store): - base = _seed_cohorts(store) - asyncio.run(rollup.run(store, today=base + timedelta(days=40))) - - def metric(name): - rows = _rows( - store, - "SELECT value FROM daily_metrics WHERE day = $1 AND metric = $2", - base.isoformat(), - name, - ) - return rows[0][0] if rows else None - - assert metric("cohort_size") == 3 - assert metric("retention_d1") == 2 - assert metric("retention_d7") == 1 - assert metric("retention_d30") == 0 - assert metric("dau") == 3 - assert metric("error_rate") == pytest.approx(6 / 30) - - -def test_retention_survives_the_loss_of_the_raw_events(store): - """install_days is written on the ingest path precisely so this holds.""" - base = _seed_cohorts(store) - asyncio.run(rollup.run(store, today=base + timedelta(days=40))) - before = _rows(store, "SELECT COUNT(*) FROM daily_metrics")[0][0] - - asyncio.run(store.execute("DELETE FROM events")) - asyncio.run(rollup.run(store, today=base + timedelta(days=40))) - - assert _rows(store, "SELECT COUNT(*) FROM daily_metrics")[0][0] >= before - 5 - assert ( - _rows( - store, - "SELECT value FROM daily_metrics WHERE metric = $1 AND day = $2", - "retention_d7", - base.isoformat(), - )[0][0] - == 1 - ) - - -def test_the_rollup_summarises_a_real_ingest(client, store): - client.post( - "/v1/events", - json=_envelope( - [ - _event(), - _event(props={"name": "bots", "surface": "telegram"}), - _event( - name="agent_turn", - props={ - "kind": "chat", - "provider": "openai", - "model": "gpt-5", - "tool_calls": 4, - "outcome": "done", - }, - ), - _event( - name="error", - props={ - "where": "handlers.bots", - "exc_type": "ValueError", - "sig": "ab12", - }, - ), - _event( - name="confirmation", props={"tool": "execute", "decision": "allow"} - ), - ] - ), - ) - asyncio.run(rollup.run(store)) - - metrics = { - (metric, dim): value - for metric, dim, value in _rows( - store, "SELECT metric, dim, value FROM daily_metrics" - ) - } - assert metrics[("event_rank", "command")] == 2 - assert metrics[("command_rank", "bots")] == 1 - assert metrics[("agent_provider", "openai")] == 1 - assert metrics[("agent_tool_calls_avg", "")] == 4 - assert metrics[("confirmation_decision", "allow")] == 1 - assert metrics[("error_group", "ValueError:ab12")] == 1 - assert metrics[("version_share", "54ad4dc")] == 1 - - -def _statements(path): - """Split a query file into runnable statements. - - Comments come out first: they are prose, and prose contains semicolons. - """ - body = "\n".join( - line - for line in path.read_text(encoding="utf-8").splitlines() - if not line.strip().startswith("--") - ) - return [s.strip() for s in body.split(";") if s.strip()] - - -def test_every_query_runs_and_answers_its_question(client, store): - base = _seed_cohorts(store) - client.post("/v1/events", json=_envelope()) - asyncio.run(rollup.run(store, today=base + timedelta(days=40))) - - results = {} - for path in sorted(QUERIES.glob("*.sql")): - results[path.name] = [_rows(store, sql) for sql in _statements(path)] - - assert set(results) == { - "adoption.sql", - "agent_economics.sql", - "feature_usage.sql", - "reliability.sql", - "retention.sql", - } - # Each one has to return numbers, not merely parse. - assert any(row[1] == "dau" for row in results["adoption.sql"][0]) - assert (base.isoformat(), 3, 2, 1, 0) in [ - tuple(row) for row in results["retention.sql"][0] - ] - assert any(row[1] == "command" for row in results["feature_usage.sql"][0]) - assert results["reliability.sql"][0] - assert results["agent_economics.sql"] - - -def test_no_query_can_correlate_installs_through_user_hash(): - """`user_hash` is salted per install, so a global GROUP BY on it would be - inventing a cross-install identity the client deliberately refused to give.""" - for path in QUERIES.glob("*.sql"): - for sql in _statements(path): - assert "user_hash" not in sql - - -def test_sqlite_retention_deletes_what_postgres_would_drop(store, monkeypatch): - monkeypatch.setenv("TELEMETRY_RETENTION_DAYS", "30") - old = datetime.now(timezone.utc) - timedelta(days=200) - asyncio.run( - store.execute( - "INSERT INTO events (id, install_id, ts, received_at, name, props)" - " VALUES ($1, $2, $3, $4, $5, $6)", - uuid.uuid4().hex, - uuid.uuid4().hex, - old, - old, - "heartbeat", - "{}", - ) - ) - - assert ( - asyncio.run( - rollup.maintain_partitions(store, datetime.now(timezone.utc).date()) - ) - == [] - ) - assert _rows(store, "SELECT COUNT(*) FROM events")[0][0] == 0 - - -def test_partition_names_run_a_month_ahead(): - """The Postgres path builds names from dates it computed, never from input.""" - assert rollup._next_month(rollup._month_start(datetime(2026, 12, 31).date())) == ( - datetime(2027, 1, 1).date() - ) - - -# ── Packaging ──────────────────────────────────────────────────────────── - - -def test_asyncpg_is_an_extra_and_never_a_runtime_dependency(): - """`uv sync` without the extra must not pull a database driver into a bot.""" - manifest = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8")) - project = manifest["project"] - - assert not any("asyncpg" in dep for dep in project["dependencies"]) - extras = project["optional-dependencies"] - assert any("asyncpg" in dep for dep in extras["telemetry-server"]) From 4f455e30ba0d4084b5ab1512fa646252a4ad5246 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 01:11:10 +0300 Subject: [PATCH 020/116] (feat) add tools to create strataegies at agent level --- agents/condor/skills/agent_builder/SKILL.md | 67 +++++-------------- agents/delta_neutral_funding_agent/AGENT.md | 2 + .../strategies/ema_trend_loop/strategy.md | 11 +++ agents/market_making_expert/AGENT.md | 3 + agents/solana_dex_lp_expert/AGENT.md | 3 + agents/xrpl_market_maker/AGENT.md | 2 + 6 files changed, 38 insertions(+), 50 deletions(-) diff --git a/agents/condor/skills/agent_builder/SKILL.md b/agents/condor/skills/agent_builder/SKILL.md index 7215d500..41800695 100644 --- a/agents/condor/skills/agent_builder/SKILL.md +++ b/agents/condor/skills/agent_builder/SKILL.md @@ -164,56 +164,23 @@ Only if the user wants the agent to act autonomously. The agent can already loop this step — `start_agent(strategy_id="")` ticks a default playbook driven by its AGENT.md — but that default is deliberately generic. A **strategy** is the specific tick playbook the engine runs in a **session**, and it is what you want for anything that -trades. Make clear the loop does NOT have to trade — define the tick task however the -user wants: - -- read routine X's output and **decide whether to trade** (create/stop executors), -- or just **send a report / notification**, -- or watch a condition and act only when it's met, - -…running at a **frequency the user sets** (`frequency_sec`). - -If the loop creates executors, BEFORE writing the strategy fetch the schema for every -executor type it will use — `manage_executors(executor_type="grid_strike")`, etc. — and -embed the required fields/types directly into the instructions; the tick LLM has no other -way to learn them. Same for any controller config it manages (`manage_controllers`). - -``` -manage_trading_agent( - action="create_strategy", - agent_slug="", # the agent must already exist - name="BRL MM", - description="…", - instructions="", - # agent_key omitted → inherits the owning agent's model; overridable at launch - config={"connector_name": "binance", "frequency_sec": 60, - "total_amount_quote": 100, "execution_mode": "loop"} -) -``` - -Strategy instructions (the tick system prompt) MUST include: **Objective**; **Analysis** -(which routine to call by name and how to read it); **Decision logic** (act / report / -hold); and — only if it trades — an **Executor config** with the FULL schema (every -required field, type, range, ordering rule), **Parameter inference** (how to derive -prices/side/TP from routine output + market data), **Risk rules** (max position, position -limits, stop behaviour), and **Error recovery** (on a failed create, re-fetch the schema, -fix, retry once, journal it). - -**Dry run before live** (if it trades): -``` -manage_trading_agent(action="start_agent", strategy_id="", - config={"execution_mode": "dry_run", "agent_key": "ollama:llama3.1", - "trading_context": "Trade BTC-USDT on binance_perpetual", - "frequency_sec": 60, "total_amount_quote": 100, - "risk_limits": {"max_position_size_quote": 200, "max_open_executors": 3}}) -``` -Review with `trading_agent_journal_read(agent_id=…, section="run:1")`: routines called -right, decision logic sound, conditional language ("would place…"), no real create/stop -calls, risk rules respected. Don't go live until the user is satisfied. - -**Go live:** offer `run_once` (single live tick), `loop` (continuous), or `loop` + -`max_ticks`. Confirm the live model, start, confirm it's running, give monitoring -commands. Always include risk limits when a loop agent can trade. +trades. + +**How a strategy is authored, dry-run and launched lives in the shared `strategy_builder` +playbook — read it (`manage_skill(action="read", name="strategy_builder")`) and follow it, +passing `agent_slug=""`.** It is the single source of truth, and it is shared +precisely so an agent can give *itself* a loop without coming back through you. Don't +restate its mechanics here; your job at this step is only to: + +- decide **with the user** whether a dedicated strategy is warranted at all, +- make clear the loop does NOT have to trade — it can read routine X's output and decide + to trade, send a report, or watch a condition — at a **frequency the user sets** + (`frequency_sec`), +- then run `strategy_builder` for the agent you just created. + +If the agent is capable enough to author its own loop, prefer handing it the job: +`consult(agent="", task="give yourself a loop that …")`. It reads the same +shared playbook and knows its own domain better than you do. ## Monitoring existing agents 1. `manage_trading_agent(action="list_agent_definitions")` — all agents, with their diff --git a/agents/delta_neutral_funding_agent/AGENT.md b/agents/delta_neutral_funding_agent/AGENT.md index e1b79387..07cd0bc0 100644 --- a/agents/delta_neutral_funding_agent/AGENT.md +++ b/agents/delta_neutral_funding_agent/AGENT.md @@ -11,6 +11,8 @@ tools: - manage_bots - manage_routines - search_history +- manage_trading_agent +- trading_agent_journal_read - manage_memory - manage_skill when_to_consult: When the user asks about delta-neutral funding strategies on HIP-3 diff --git a/agents/directional_trader/strategies/ema_trend_loop/strategy.md b/agents/directional_trader/strategies/ema_trend_loop/strategy.md index 02199e63..188ca56b 100644 --- a/agents/directional_trader/strategies/ema_trend_loop/strategy.md +++ b/agents/directional_trader/strategies/ema_trend_loop/strategy.md @@ -119,6 +119,17 @@ Write a state entry: If you observed anything useful this tick (e.g. a config underperforming vs its backtest, a pair in a choppy regime, a config that caught a clean trend), write a learning entry. These learnings will inform Steps 1–3 on future ticks. +### Step 8 — Notify user +Call `send_notification` with a brief tick summary. Keep it short (4–6 lines): +``` +🤖 EMA Trend Loop — Tick +Bot: +Controllers: | ... +Session PnL: +Action: +``` +Always send this even if nothing changed — the user needs to know the agent is alive. + ## Risk rules - Max global drawdown: 15% of total_amount_quote - Max per-position loss before stopping controller: 10% diff --git a/agents/market_making_expert/AGENT.md b/agents/market_making_expert/AGENT.md index b8efc86b..0f821f84 100644 --- a/agents/market_making_expert/AGENT.md +++ b/agents/market_making_expert/AGENT.md @@ -10,6 +10,9 @@ tools: - manage_controllers - manage_bots - search_history +- manage_routines +- manage_trading_agent +- trading_agent_journal_read - manage_memory - manage_skill when_to_consult: When the user asks about market regime, whether spreads are appropriate, diff --git a/agents/solana_dex_lp_expert/AGENT.md b/agents/solana_dex_lp_expert/AGENT.md index d55aec02..1f7a2d85 100644 --- a/agents/solana_dex_lp_expert/AGENT.md +++ b/agents/solana_dex_lp_expert/AGENT.md @@ -11,6 +11,9 @@ tools: - get_portfolio_overview - get_market_data - search_history +- manage_routines +- manage_trading_agent +- trading_agent_journal_read - manage_memory - manage_skill when_to_consult: When the user asks which Solana memecoin pools to LP now, how to rank diff --git a/agents/xrpl_market_maker/AGENT.md b/agents/xrpl_market_maker/AGENT.md index 2a365400..8e51c5c9 100644 --- a/agents/xrpl_market_maker/AGENT.md +++ b/agents/xrpl_market_maker/AGENT.md @@ -11,6 +11,8 @@ tools: - manage_controllers - manage_bots - manage_routines +- manage_trading_agent +- trading_agent_journal_read - manage_skill - send_notification when_to_consult: When the user asks about quoting on the XRP Ledger DEX — whether a From be0613b802349228e243f0b8ab5025d95419fe1a Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 01:11:16 +0300 Subject: [PATCH 021/116] (feat) add tools to create strataegies at agent level --- agents/meteora_launch_lp/AGENT.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agents/meteora_launch_lp/AGENT.md b/agents/meteora_launch_lp/AGENT.md index 3ddc68d6..6bcfc2c4 100644 --- a/agents/meteora_launch_lp/AGENT.md +++ b/agents/meteora_launch_lp/AGENT.md @@ -11,6 +11,9 @@ tools: - get_portfolio_overview - get_market_data - send_notification +- manage_routines +- manage_trading_agent +- trading_agent_journal_read - manage_memory - manage_skill when_to_consult: When the user asks whether a freshly-graduated Meteora DAMM v2 pool From e62cd46d997af47ae2a1a50f54abbad1ff6f50f8 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 01:11:23 +0300 Subject: [PATCH 022/116] (feat) add github issue template --- .github/ISSUE_TEMPLATE/bug_report.yml | 106 +++++++++++++-------- .github/ISSUE_TEMPLATE/feature_request.yml | 89 ++++++++++------- 2 files changed, 124 insertions(+), 71 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7ebbf5cb..6362e061 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,40 +1,70 @@ name: Bug Report -description: Create a bug report to help us improve -title: "Bug Report" -labels: bug +description: Something in Condor does not work the way it should +title: "[Bug] " +labels: [bug] body: - - type: markdown - attributes: - value: | - ## **Before Submitting:** - - * Please edit the "Bug Report" to the title of the bug or issue - * Please make sure to look on our GitHub issues to avoid duplicate tickets - * You can add additional `Labels` to support this ticket (connectors, strategies, etc) - * If this is something to do with installation and how to's we would recommend to visit our [Hummingbot docs](https://hummingbot.org/docs/) and [Discord server](https://discord.gg/hummingbot) - - type: textarea - id: what-happened - attributes: - label: Describe the bug - description: A clear and concise description of the bug or issue. Please make sure to add screenshots and error message to help us investigate - placeholder: Tell us what happened? - validations: - required: true - - type: textarea - id: reproduce - attributes: - label: Steps to reproduce - description: A concise description of the steps to reproduce the buggy behavior - value: | - 1. - 2. - 3. - validations: - required: true - - type: textarea - id: attachment - attributes: - label: Attach required files - description: Please attach your config file and log file located on the "../gateway/logs/" folder. It would be really helpful to triage the issue. - validations: - required: false + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. + + **Before you file:** search the [open issues](https://github.com/hummingbot/condor/issues?q=is%3Aissue) first — a duplicate splits the discussion in two. + + **Never paste secrets.** API keys, wallet private keys, seed phrases, your `.env` or an unredacted `config.yml` do not belong in a public issue. If the dashboard filled in the "Environment" box below, read it before you submit. + + - type: dropdown + id: area + attributes: + label: Area + description: Where in Condor did this happen? (The dashboard preselects this for you.) + options: + - Agents & chat + - Portfolio + - Bots & controllers + - Trading & executors + - Routines & reports + - Settings & connections + - Dashboard (other) + - Telegram bot + - Gateway / DEX + - Not sure + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you expected, and what you got instead. Screenshots help. + placeholder: The portfolio chart stays blank after I switch servers, but the balances table updates. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: The shortest path from a fresh session to the bug. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: attachment + attributes: + label: Environment + description: | + Condor version, page, browser and recent errors. The dashboard's "Report an issue" button (🐛, top right) fills this in automatically; otherwise paste the output of `git rev-parse --short HEAD` and how you run Condor (source, Docker). + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Relevant lines from the bot log or the browser console. Redact keys, hosts and wallet addresses first. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 13eb0bc9..147822c1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,35 +1,58 @@ -name: Feature request -description: Suggest an idea that will improve the Hummingbot codebase -title: "Feature Request" -labels: enhancement +name: Feature Request +description: Suggest something Condor should be able to do +title: "[Feature] " +labels: [enhancement] body: - - type: markdown - attributes: - value: | - ## **Before Submitting:** + - type: markdown + attributes: + value: | + Thanks for the idea. - * Please edit the "Feature Request" to the title of the feature - * Please make sure to look on our GitHub issues to avoid duplicate tickets - * You can add additional `Labels` to support this ticket (connectors, strategies, etc) - * If this is something to do with installation and how to's we would recommend to visit our [Discord server](https://discord.gg/hummingbot) and [Hummingbot docs](https://hummingbot.org/docs/) - - type: textarea - id: feature-suggestion - attributes: - label: Feature Suggestion - description: A clear and concise description of the feature request. If you have looked at the code and know exactly what code changes are needed then please consider submitting a pull request instead. - placeholder: How you want to achieve the desired behavior - validations: - required: true - - type: textarea - id: feature-impact - attributes: - label: Impact - description: A succinct description of why you want the desired behavior specified above. - placeholder: The desired behavior will allow me to.. - validations: - required: true - - type: textarea - id: feature-additional-context - attributes: - label: Additional context - description: Add any other context or screenshots about the feature request here (optional) + **Before you file:** search the [open issues](https://github.com/hummingbot/condor/issues?q=is%3Aissue) — someone may already be arguing for it, and a second voice on one thread carries further than a second thread. + + If you already know what the change looks like in code, a pull request is welcome instead. + + - type: dropdown + id: area + attributes: + label: Area + description: Which part of Condor would this touch? (The dashboard preselects this for you.) + options: + - Agents & chat + - Portfolio + - Bots & controllers + - Trading & executors + - Routines & reports + - Settings & connections + - Dashboard (other) + - Telegram bot + - Gateway / DEX + - Not sure + validations: + required: true + + - type: textarea + id: feature-suggestion + attributes: + label: What should it do + description: Describe the behavior you want, not the implementation. + placeholder: Let me filter the executors table by connector, the way the bots table already filters by status. + validations: + required: true + + - type: textarea + id: feature-impact + attributes: + label: Why it matters + description: What this would let you do that you cannot do today, and how often you hit it. + placeholder: With 40 executors open I scroll past everything to find the two on Hyperliquid. + validations: + required: true + + - type: textarea + id: feature-additional-context + attributes: + label: Additional context + description: Mockups, links, the workaround you use today, or the version you are on. + validations: + required: false From 4eecf43ce024845b6b8c359df28f223c61d1e487 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 12 Aug 2026 01:12:27 +0300 Subject: [PATCH 023/116] (feat) add issue erpoting --- condor/web/app.py | 2 + condor/web/routes/meta.py | 37 +++ frontend/src/components/ErrorBoundary.tsx | 59 ++++- frontend/src/components/ReportIssueDialog.tsx | 250 ++++++++++++++++++ frontend/src/components/layout/AppShell.tsx | 15 +- frontend/src/lib/api.ts | 12 + frontend/src/lib/diagnostics.ts | 183 +++++++++++++ frontend/src/lib/github-issue.ts | 128 +++++++++ frontend/src/lib/page-context.ts | 122 +++++++++ frontend/src/lib/shared-socket.ts | 11 + frontend/src/lib/websocket.ts | 5 + frontend/src/main.tsx | 5 + uv.lock | 8 +- 13 files changed, 828 insertions(+), 9 deletions(-) create mode 100644 condor/web/routes/meta.py create mode 100644 frontend/src/components/ReportIssueDialog.tsx create mode 100644 frontend/src/lib/diagnostics.ts create mode 100644 frontend/src/lib/github-issue.ts create mode 100644 frontend/src/lib/page-context.ts diff --git a/condor/web/app.py b/condor/web/app.py index c85930f5..1300ea72 100644 --- a/condor/web/app.py +++ b/condor/web/app.py @@ -22,6 +22,7 @@ conversations, executors, market, + meta, portfolio, positions, reports, @@ -80,6 +81,7 @@ def create_app() -> FastAPI: app.include_router(positions.router, prefix="/api/v1") app.include_router(backtesting.router, prefix="/api/v1") app.include_router(market.router, prefix="/api/v1") + app.include_router(meta.router, prefix="/api/v1") app.include_router(ws.router, prefix="/api/v1") app.include_router(agents.router, prefix="/api/v1") app.include_router(routines.router, prefix="/api/v1") diff --git a/condor/web/routes/meta.py b/condor/web/routes/meta.py new file mode 100644 index 00000000..bf5003cd --- /dev/null +++ b/condor/web/routes/meta.py @@ -0,0 +1,37 @@ +"""Build identity for the dashboard. + +One endpoint, and it exists for a single reason: a bug report that does not say +which commit it came from costs a round trip to find out. The fields are the +same ones the telemetry context already computes for its own envelope — a short +commit, a branch, a runtime — so nothing new is collected here, it is only shown +to the logged-in user who is about to paste it into an issue. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from condor.telemetry import context +from condor.web.auth import get_current_user +from condor.web.models import WebUser + +router = APIRouter(prefix="/meta", tags=["meta"]) + + +@router.get("/env") +async def get_env(user: WebUser = Depends(get_current_user)) -> dict: + """Version and platform of this install. + + Authenticated: the commit an install runs is not a secret, but it is a + detail about someone's deployment and there is no reason to hand it to an + anonymous caller. + """ + app = context.app() + return { + "version": app["version"], + "branch": app["branch"], + "python": app["python"], + "os": app["os"], + "arch": app["arch"], + "in_docker": app["in_docker"], + } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 63ca7a2e..6bbe6fd3 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,5 +1,9 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { SERVER_KEY } from "@/lib/auth"; +import { areaForRoute, buildDiagnostics } from "@/lib/diagnostics"; +import { openIssueDraft } from "@/lib/github-issue"; + function isChunkLoadError(error: Error): boolean { const msg = error.message || ""; return ( @@ -18,17 +22,20 @@ interface Props { interface State { hasError: boolean; error: Error | null; + /** React's component stack — the most useful half of a crash report. */ + componentStack: string | null; } export class ErrorBoundary extends Component { - state: State = { hasError: false, error: null }; + state: State = { hasError: false, error: null, componentStack: null }; static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; + return { hasError: true, error, componentStack: null }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error("[ErrorBoundary]", error, info.componentStack); + this.setState({ componentStack: info.componentStack ?? null }); // Auto-reload once on chunk/module import failures (stale deploys) if (isChunkLoadError(error)) { @@ -43,10 +50,46 @@ export class ErrorBoundary extends Component { componentDidUpdate(prevProps: Props) { if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { - this.setState({ hasError: false, error: null }); + this.setState({ hasError: false, error: null, componentStack: null }); } } + /** + * File the crash the boundary just caught. + * + * The message and component stack are the report — asking the user to + * retype them is asking for a worse one. Read from `window`/`localStorage` + * rather than router and server context: a class component that renders + * *because* the tree below it failed is the wrong place to add hooks. + */ + private reportCrash = () => { + const error = this.state.error; + const route = window.location.pathname + window.location.search; + const stack = [ + error?.stack || `${error?.name}: ${error?.message}`, + this.state.componentStack ? `\n\nComponent stack:${this.state.componentStack}` : "", + ].join(""); + + openIssueDraft({ + kind: "bug", + title: `Crash: ${(error?.message || "unknown error").slice(0, 80)}`, + area: areaForRoute(route), + description: + "The dashboard crashed and showed the \"Something went wrong\" screen.\n\n" + + `Error: ${error?.message || "unknown"}`, + // The boundary knows the route, not the clicks that led there — the one + // thing it cannot fill in is left for the reporter to write. + detail: "1. \n2. \n3. ", + logs: "```\n" + stack + "\n```", + diagnostics: buildDiagnostics({ + kind: "bug", + route, + server: localStorage.getItem(SERVER_KEY), + env: null, + }), + }); + }; + render() { if (this.state.hasError) { return ( @@ -63,13 +106,21 @@ export class ErrorBoundary extends Component { if (this.state.error && isChunkLoadError(this.state.error)) { window.location.reload(); } else { - this.setState({ hasError: false, error: null }); + this.setState({ hasError: false, error: null, componentStack: null }); } }} className="rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:opacity-90" > {this.state.error && isChunkLoadError(this.state.error) ? "Reload" : "Try Again"} + {this.state.error && !isChunkLoadError(this.state.error) && ( + + )} ); diff --git a/frontend/src/components/ReportIssueDialog.tsx b/frontend/src/components/ReportIssueDialog.tsx new file mode 100644 index 00000000..3a5e66c1 --- /dev/null +++ b/frontend/src/components/ReportIssueDialog.tsx @@ -0,0 +1,250 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react"; +import { useLocation } from "react-router-dom"; + +import { useEscapeKey } from "@/hooks/useEscapeKey"; +import { useServer } from "@/hooks/useServer"; +import { api } from "@/lib/api"; +import { areaForRoute, buildDiagnostics } from "@/lib/diagnostics"; +import { ISSUE_REPO, openIssueDraft, type IssueKind } from "@/lib/github-issue"; +import { AREAS, type Area } from "@/lib/page-context"; + +/** + * "Report an issue" — writes a GitHub issue draft, does not file it. + * + * The button hands off to GitHub's own form with every field prefilled; the + * user submits it themselves. That keeps the report attributed to them, keeps + * the reply thread in their notifications, and keeps this install free of a + * token that could write to the tracker. + * + * The diagnostics block is opt-in and shown verbatim before it leaves: it ends + * up in a public issue, so the person attaching it gets to read it first. + */ +export function ReportIssueDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const [kind, setKind] = useState("bug"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [detail, setDetail] = useState(""); + const [area, setArea] = useState(null); + const [includeDiagnostics, setIncludeDiagnostics] = useState(true); + const [showDiagnostics, setShowDiagnostics] = useState(false); + + const { pathname, search } = useLocation(); + const { server } = useServer(); + // Version never changes while the tab is open, and a failed fetch must not + // block the report — the block just says the version is unavailable. + const { data: env } = useQuery({ + queryKey: ["meta-env"], + queryFn: api.getEnv, + staleTime: Infinity, + retry: false, + enabled: open, + }); + + useEscapeKey(open, onClose); + + if (!open) return null; + + const isBug = kind === "bug"; + const route = pathname + search; + // The page the user is on is the best guess at the area, and the best guess + // is what a dropdown should open on — `area` only holds an explicit override. + const effectiveArea = area ?? areaForRoute(route); + const diagnostics = buildDiagnostics({ + kind, + route, + server, + env: env ?? null, + }); + const canSubmit = title.trim().length > 0 && description.trim().length > 0; + + const submit = () => { + if (!canSubmit) return; + openIssueDraft({ + kind, + title, + area: effectiveArea, + description, + detail, + diagnostics: includeDiagnostics ? diagnostics : undefined, + }); + onClose(); + setTitle(""); + setDescription(""); + setDetail(""); + setArea(null); + }; + + const inputClass = + "w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"; + + return ( +
+
e.stopPropagation()} + > +

Report an issue

+

+ Opens a prefilled issue on{" "} + {ISSUE_REPO} in a new tab. You review and + submit it under your own GitHub account — nothing is sent from here. +

+ +
+ {(["bug", "feature"] as const).map((k) => ( + + ))} +
+ +
+
+ + setTitle(e.target.value)} + className={inputClass} + placeholder={ + isBug ? "Portfolio shows a blank chart" : "Filter executors by connector" + } + autoFocus + /> +
+ +
+ + +

+ Preselected from the page you were on. +

+
+ +
+ +