Feat/trade panel dex - #203
Conversation
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
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
…position 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
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
… (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.
`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.
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.
`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
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
…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.
…RR-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.
`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 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.
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.
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.
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.
…AT-024) 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.
…T (FEAT-024) 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.
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.
|
Too many files changed for review (336 files, 100 file limit). Bypass the limit by tagging |
Gateway's Orca listing nulls the volume, fees and price that make a pool worth judging, and its pagination is stuck on page one. Orca's own v2 API answers with all of it, plus the whole ranked set in hand — so has_more is exact there, where gecko and Gateway can only infer it from a full page. Adds source=orca to /dex/pools alongside gecko and gateway, reading through orca_api and normalizing whirlpool rows into the shared pool shape. Where Orca reports no realized fees, the yield is derived from volume and the fee tier rather than left blank. Gateway stays the fallback when Orca is rate-limiting: a degraded row beats an empty table, and the browser's empty state is something a user can act on where a 502 is not.
Typing an LP range into a form means reading prices off the chart and transcribing them back. Each price field now claims a pick slot, so a click on the chart fills the bound it belongs to: the two range bounds and both auto-close triggers, with the lower limit drawn as an extra line. The auto-close limits were also anchored to absolutes, which put a 1% range's trigger nine range-widths away — a position could sit out of range indefinitely without ever firing. They are now a multiple of the range's own half-width, so they move with the preset. Separately, the balance bar split `trading_pair` to name its tokens. A DEX pair is `<base_mint>-<quote_symbol>`, so the base lookup went hunting for a balance under a mint address and found none, and a Solana pool showed its quote balance alone. Callers that know the real symbols now pass them.
Six regressions that were fixed but never pinned: the controller status field that reports a hardcoded "running" regardless of the kill switch, the wallet import that promoted a key to chain default with the box unticked, the issue-form prefill that GitHub drops silently when a field id is renamed, what a bot-mode session can reconstruct about its own executors, the /update pipeline's frontend rebuild and clean restart, and the build-identity endpoint's fields and its refusal to answer anyone logged out.
Black and isort are already clean tree-wide, so --check can gate main rather than merely report. Adds an import smoke test for the core modules and the frontend's own lint and build.
Backpack MM covers the perp MM rewards programme — volume thresholds, market selection and rotation, deploy and ongoing operations. Brigado researches the BRL market making operation: USDT-BRL spreads, BTC/fiat volume, bot performance. Orca LP Expert judges concentrated liquidity on tokenized real-world assets, from pool attractiveness through range selection to hold-or-exit. Definitions and routines only; the runtime stores, journals and delegation records stay untracked as usual.
condor/fetchers declares it must not import handlers/, and condor.dex_candles, condor.fetchers.connectors and the dex/market web routes all broke that rule to reach handlers.dex.pool_data — each with a function-scoped import, one of them with a comment apologising for it. The module they were reaching for is 2,000 lines of GeckoTerminal/Gateway fetching with no Telegram import in it: a data layer stranded in the wrong package. Move it to condor/pool_data.py, with the Orca HTTP client it owns (condor/orca_api.py), and point every caller at the new path. Core now imports without pulling the handlers tree in at all. Its cache calls move from handlers/dex/_shared to condor.cache directly, keeping the "_cache" namespace explicit: the Telegram handlers invalidate that namespace by group, and entries written anywhere else would outlive an invalidation the handler believes it performed.
…r it PERF-137 coalesced GET /market/candles on the route's own cache key. That key carries what the upstream window does not — the trading pair, the server name — so two requests that resolve to the very same GeckoTerminal window each opened their own request and spent the rate budget twice. The route guard cannot see that they converge; only the fetch can. So fetch_ohlcv now coalesces on the upstream request itself: pool, timeframe, currency, priced side and window. Two charts of one pool labelled by different pairs, the dashboard's shared cache and a chat's user_data, a caller with no cache at all — one gecko request between them. The per-caller TTL caches above it and the route guard are untouched; they only ever helped once an answer had landed, which is precisely not the stampede case. This reuses `_single_flight`, which pool_data already owns and which already carries both hardenings the item asked for: it refuses a done() task, so a finished fetch lingering before its done-callback cannot hand a fresh request the previous outcome, and awaiters shield the shared task, so the viewer who navigates away cannot cancel the fetch the others are waiting on. A failing fetch reaches every waiter and is cached by nobody.
Rebasing onto #203 moves this branch onto a base that enforces `black --check .` and `isort --check .` in CI, which main did not. The code was written to match the surrounding pre-reformat style, so it needs a pass with the locked tools (black 26.1.0, isort 8.0.1). Formatting only — no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj
#202 renames the smart_money_flow agent to derive_options_trader and moves both strategies from the opencode custom endpoint onto claude-acp:sonnet. It was branched from main before #203 added the GeckoTerminal rate-gate guard, so its onchain_flow routine trips that test once #203 lands. Folded in here and fixed in the following commit rather than left to break main after merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj
#202 was branched from main before #203 landed, so its files miss two gates that #203 adds and that this branch now sits behind: - `options_flow.py` failed `black --check` and `isort --check`, which #203's CI enforces repo-wide. Formatting only. - `options_flow.py` line 433 wrote a Markdown underscore escape as `\_` inside a non-raw string. Python does not recognise `\_`, so it kept the two characters but raised SyntaxWarning (an error in a future version). Doubled to `\\_`: identical rendered output, no warning. The GeckoTerminal rate-gate guard needed no fix here. #202 only *renamed* `onchain_flow.py`; #203 had already rewritten that file to call `pool_data.gecko_request`, so the merge took the rename with the rate-gated content and `test_nobody_hand_builds_a_geckoterminal_url` passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj
|
A GeckoTerminal throttle is reported to the user as "this pool does not exist" Repro: open several DEX pool pages in quick succession — browse the list, open a pool, go back, open another. Each pool page costs a pool lookup plus OHLCV, on top of the trending list's polling. Within a few pool changes the budget (
The pool exists and is fine. From the logs — 4 real 429s, then 26 calls rejected locally by the breaker: The transient throttle becomes a permanent-sounding absence across three hops:
The docstring on that endpoint makes the collapse explicit:
That holds for what to draw but not for what to say. "Nothing to draw" is identical in both cases; "no pool exists here" and "rate-limited, retrying in 7s" are not. One is permanent and dead-ends the user, the other self-heals in seconds — and the breaker knows the remaining cooldown to the tenth of a second before discarding it. The failure is also self-inflicted and most likely to hit exactly when someone is exploring pools quickly, which is the feature's happy path. Suggested shape, ~a dozen lines:
|
…pty state - Add "Interval" and "Window" labels with a divider in the DEX pool chart toolbar so the two button groups aren't ambiguous. - Suppress the "No pools found." table when a pasted pool address already resolved to a direct match shown above it.
Rebasing onto #203 moves this branch onto a base that enforces `black --check .` and `isort --check .` in CI, which main did not. The code was written to match the surrounding pre-reformat style, so it needs a pass with the locked tools (black 26.1.0, isort 8.0.1). Formatting only — no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj
- `options_flow.py` failed `black --check` and `isort --check`, which #203's CI enforces repo-wide. Formatting only. - `options_flow.py` line 433 wrote a Markdown underscore escape as `\_` inside a non-raw string. Python does not recognise `\_`, so it kept the two characters but raised SyntaxWarning (an error in a future version). Doubled to `\\_`: identical rendered output, no warning. The GeckoTerminal rate-gate guard needed no fix here. #202 only *renamed* `onchain_flow.py`; #203 had already rewritten that file to call `pool_data.gecko_request`, so the merge took the rename with the rate-gated content and `test_nobody_hand_builds_a_geckoterminal_url` passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj









DEX trading and LP management arrive in the web dashboard, backed by a pool-data
core that every gecko caller now shares. Alongside that: an opt-in telemetry
stack, ad-hoc
run_codeexecution, in-app issue reporting, CI, and a longhardening sweep from the improvements backlog.
109 commits · 322 files · ~40k additions on top of
main.1. DEX in the trade panel (FEAT-041 / FEAT-042)
A gateway network can now be picked in the trade panel exactly like
binance.fetch_gateway_networks— a sibling source of truth toconnected-exchanges(which filters DEX out by design). Keeps only networks present in
NETWORK_TO_GECKO,so a selectable network can never produce an empty chart. Registered
strict=True,so an unreachable gateway retries instead of caching "this server has no DEX".
GET /market/venuesserves per-connectortraits (order book? trading rules? LP? which execution strategies?) instead of one
boolean the panel was overloading for four different questions.
xrplis the proofcase: a real order book whose charts come from GeckoTerminal.
connector-capabilities.tsclassifies by membership in the server's gateway-networklist rather than by name — the backend already carries four disagreeing connector
predicates and a frontend prefix list would have become the fifth.
GET /market/dex-poolresolves the pool a DEX pair trades in andwhether it can take an LP position.
lp_provider_for_dexmatches on (brand, product),because GeckoTerminal dex ids are not a stable vocabulary (
uniswap_v3,uniswap-v3-base,uniswap_v3_arbitrum). A router or plain-AMM pool returns200withlp_supported: false— a state to render, not an error to handle.
lp_executoris structurally a grid, so it reusesGridBox: onecomputeLpOverlayand onecase "lp":, no new drawing code.ExecutorTypegains
lpand the parallelSupportedExecutorTypeunion is gone, sotscnames everyexhaustive site instead of failing silently.
it belongs to. Auto-close triggers are now a multiple of the range's own half-width
(anchored to absolutes, a 1% range put its trigger nine range-widths away and could never fire).
2. A DEX page where the pool is the unit
/dexand/dex/:network/:address— pool discovery, then the pool itself.condor/web/routes/dex.py(new): pools, dexes, chains, pools-by-address, pool detail,and liquidity bins, all server-scoped.
nulls the volume, fees and price that make a pool worth judging and its pagination is stuck
on page one; Orca answers with all of it plus an exact
has_more. Gateway stays the fallbackwhen Orca rate-limits — a degraded row beats an empty table.
configured LP range drawn across it.
/tradebecomes CLOB-only; gateway networks route to/dex.3.
condor/pool_data.py— pool data is core, not a Telegram handlerMoved out of
handlers/dex/(ARCH-144) and grown to ~2.3k lines: the rate gate, circuitbreaker and single-flight that already lived there now bind every GeckoTerminal caller.
The Telegram browser, the gateway token importer, the MCP tools and four routines each built
their own client or hand-rolled an
api.geckoterminal.comURL — invisible to the accounting,spending the same per-IP quota, and leaking an httpx pool per invocation. Two guard tests hold
the line: nobody constructs a second client, nobody hand-builds a raw URL. PERF-169 collapses
a candle window to one gecko request no matter who asks.
4. Telemetry — opt-in, and provable (FEAT-023 / FEAT-024)
Condor is self-hosted, so the project learns nothing about adoption or reliability unless an
install chooses to say. This is that choice, built around the fact that the process holds
exchange API keys.
off→emit()returns before looking atits arguments. No buffer, no spool file, no directory.
schema.pydeclares every event and property;sanitize()drops anything undeclared, so"we never collect balances, keys, pairs or prompts" is a property of the code.
CONDOR_TELEMETRY_URLunset — the shipped state— the send path is inert.
TypeHandlerin group -1, one web middlewarekeyed on the route template,
runtime/client.prompt(), plus engine/routine/confirmation/updater.MCP runs in its own process and spools to a pid-scoped file the host drains.
PRIVACY.md+tests/test_telemetry.py, where the load-bearing tests are the negative ones(a fresh install emits nothing; every declared event is fed keys/balances/prompts/a 10 KB string
and none survives; an exception's message never appears, only its type and a hash).
GET/PUT /api/v1/settings/telemetry, admin only. Turning it off deletes the buffer and outbox.condor-telemetry-server); a guard testasserts it stays extracted.
5.
run_code— a snippet where a routine runsAn ad-hoc Python snippet gets the routine environment (main process,
WebRoutineContext, cachedAPI client, report attribution) minus the file and config schema.
PyCF_ALLOW_TOP_LEVEL_AWAITkeepstraceback line numbers pointing at the caller's line; capture is a ContextVar-backed stdout proxy in a
per-run task, so concurrent runs can't read each other's output. Every run is persisted under
data/code_runs/. Surfaced asPOST /api/v1/code/runand the MCPrun_codetool — gated behindadmin or an explicit per-user grant (SEC-151), with an admin API and panel control to manage it (ARCH-177).
6. Agents, skills and charts
strategy_builderandverify_connector_supportskills.```chartfences render as interactive charts in the web chat, on both chat paths.7. Issue reporting
An in-app Report Issue dialog wired to
ErrorBoundary, collecting page context, diagnostics andGET /api/v1/meta/env, and pre-filling a GitHub issue. The.githubissue templates were rewrittento match, with a contract test tying the two together.
8. Hardening sweep (40 backlog items)
The theme is ownership and blast radius: routine instances, hooks, jobs and credentials are now
scoped to their owner rather than to a chat id; a session's server is held to the caller's reach;
one malformed row no longer 500s a whole chart.
9. Infrastructure
.github/workflows/ci.yml): black + isort, an import check, pytest, and the frontend build.isort+blacksweep over the Python tree, recorded in.git-blame-ignore-revsso it doesn't pollute blame.
that previously had none.
Review notes
The format sweep (
3fa941f) and the merge frommain(9e2d775) are the two commits worth skipping.The DEX work reads best in order:
1966535→9dbc772→58d2bb7→1822a1e→476764a→5cc1fb3,then the
/dexpage series starting atf35d4ef.