Skip to content

Feat/trade panel dex - #203

Merged
fengtality merged 117 commits into
mainfrom
feat/trade-panel-dex
Aug 18, 2026
Merged

Feat/trade panel dex#203
fengtality merged 117 commits into
mainfrom
feat/trade-panel-dex

Conversation

@cardosofede

@cardosofede cardosofede commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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_code execution, in-app issue reporting, CI, and a long
hardening 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 to connected-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".
  • Venue traits, not a CEX/DEX binaryGET /market/venues serves per-connector
    traits (order book? trading rules? LP? which execution strategies?) instead of one
    boolean the panel was overloading for four different questions. xrpl is the proof
    case: a real order book whose charts come from GeckoTerminal.
  • connector-capabilities.ts classifies by membership in the server's gateway-network
    list rather than by name — the backend already carries four disagreeing connector
    predicates and a frontend prefix list would have become the fifth.
  • LP positionsGET /market/dex-pool resolves the pool a DEX pair trades in and
    whether it can take an LP position. lp_provider_for_dex matches 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 returns 200 with lp_supported: false
    — a state to render, not an error to handle.
  • The chart draws the positionlp_executor is structurally a grid, so it reuses
    GridBox: one computeLpOverlay and one case "lp":, no new drawing code. ExecutorType
    gains lp and the parallel SupportedExecutorType union is gone, so tsc names every
    exhaustive site instead of failing silently.
  • Pick bounds off the chart — each price field claims a pick slot; a click fills the bound
    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

/dex and /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.
  • Three pool sources — gecko, gateway, and Orca's own v2 API. 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 answers with all of it plus an exact has_more. Gateway stays the fallback
    when Orca rate-limits — a degraded row beats an empty table.
  • Liquidity depth column beside the pool chart, sharing the chart's price axis, with the
    configured LP range drawn across it.
  • Favorites tab, pinning a chart to one specific pool, per-pool stats.
  • /trade becomes CLOB-only; gateway networks route to /dex.

3. condor/pool_data.py — pool data is core, not a Telegram handler

Moved out of handlers/dex/ (ARCH-144) and grown to ~2.3k lines: the rate gate, circuit
breaker 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.com URL — 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.

  • Default is silence. No consent recorded → level offemit() returns before looking at
    its arguments. No buffer, no spool file, no directory.
  • schema.py declares every event and property; sanitize() drops anything undeclared, so
    "we never collect balances, keys, pairs or prompts" is a property of the code.
  • No collector address is compiled in. With CONDOR_TELEMETRY_URL unset — the shipped state
    — the send path is inert.
  • Taps at existing seams, not call sites: one PTB TypeHandler in group -1, one web middleware
    keyed 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.
  • The collector service itself has moved to its own repo (condor-telemetry-server); a guard test
    asserts it stays extracted.

5. run_code — a snippet where a routine runs

An ad-hoc Python snippet gets the routine environment (main process, WebRoutineContext, cached
API client, report attribution) minus the file and config schema. PyCF_ALLOW_TOP_LEVEL_AWAIT keeps
traceback 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 as POST /api/v1/code/run and the MCP run_code tool — gated behind
admin 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

  • Three new agents: Backpack MM, Brigado, Orca LP Expert, with their routines and skills.
  • Agent-level strategy creation tools; strategy_builder and verify_connector_support skills.
  • Gateway wallet import from the dashboard, default-wallet handling, gateway defaults to development.
  • Configurable agent timeouts, with a settings surface.
  • ```chart fences 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 and
GET /api/v1/meta/env, and pre-filling a GitHub issue. The .github issue templates were rewritten
to match, with a contract test tying the two together.

8. Hardening sweep (40 backlog items)

Area Items
Security SEC-130 (routes fail without naming the backend), SEC-147 (server-access check becomes a dependency — 89 copies deleted), SEC-151, SEC-153, SEC-159, SEC-164, SEC-166, SEC-167, SEC-176, SEC-178, SEC-179, SEC-180
Correctness CORR-131/132 (null executor config, fail-closed pair validation), CORR-141, CORR-160/161, CORR-163, CORR-165, CORR-168, CORR-171, CORR-175, CORR-181, CORR-182
Performance PERF-136, PERF-138, PERF-169, PERF-170, PERF-173, PERF-174, parallel per-server status, executor pre-fetch, REST candle coalescing
Architecture ARCH-121 (side normalization server-side), ARCH-144, ARCH-146 (one historical-candles ladder), ARCH-148 (one atomic file write), ARCH-155, ARCH-162 (one fire-and-forget task tracker), ARCH-177, READ-172

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

  • CI (.github/workflows/ci.yml): black + isort, an import check, pytest, and the frontend build.
  • A mechanical isort + black sweep over the Python tree, recorded in .git-blame-ignore-revs
    so it doesn't pollute blame.
  • 77 test files touched or added (~14.6k lines), including guard tests for shipped behaviour
    that previously had none.

Review notes

The format sweep (3fa941f) and the merge from main (9e2d775) are the two commits worth skipping.
The DEX work reads best in order: 19665359dbc77258d2bb71822a1e476764a5cc1fb3,
then the /dex page series starting at f35d4ef.

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.
@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Too many files changed for review (336 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

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.
fengtality added a commit that referenced this pull request Aug 17, 2026
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
fengtality added a commit that referenced this pull request Aug 17, 2026
#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
fengtality added a commit that referenced this pull request Aug 17, 2026
#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
@fengtality

fengtality commented Aug 17, 2026

Copy link
Copy Markdown
Contributor
Screenshot 2026-08-17 at 1 50 05 PM

pool address is hard to find since it's right justified. i recommend putting this shortened address along with the copy and link icons below the BASE-QUOTE instead of network, and moving network into its own column next to it. also, i would change link from GeckoTerminal to the actual DEX page for the pool.

@fengtality

Copy link
Copy Markdown
Contributor

remove the redundant intervals in the DEX candle feed chart - 1h, 4h, 1d
Screenshot 2026-08-17 at 1 59 20 PM

@fengtality

fengtality commented Aug 17, 2026

Copy link
Copy Markdown
Contributor
Screenshot 2026-08-17 at 2 04 50 PM

Since Market is only selectable option, disable it just like Leverage. We should eventually add a slippage toggle here but I'm not sure if Order Executor is designed for extra_params.

Consider changing Long and Short labels to Buy and Sell, so that we don't confuse user into thinking that they are trading perps. This can be confusing if they select Short since it's not obvious when user doesn't have tokens that they can't sell/short it.

Screenshot 2026-08-17 at 2 08 34 PM

When I designed this feature for the old Condor Dashboard, I tried to make it look like a swap widget. Since we are using Order Executor, I don't think we need to do this, but here's what it looks like as a reference:
Screenshot 2026-08-17 at 2 10 14 PM

@fengtality

fengtality commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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 (_GECKO_RATE_LIMIT = 25 per _GECKO_RATE_WINDOW = 60.0s, condor/pool_data.py:297) is gone, and the next pool you open renders:

No pool at HMzvsEEm… on solana-mainnet-beta.

The pool exists and is fine. From the logs — 4 real 429s, then 26 calls rejected locally by the breaker:

14:02:03 ERROR  Error fetching OHLCV: 429 Too Many Requests
                api.geckoterminal.com/api/v2/networks/solana/pools/.../ohlcv/minute
14:02:38 ERROR  GeckoTerminal rate limit: cooling down for another 16.9s
14:02:49 INFO   pool lookup failed net=solana pool=HMzvsEEm...: cooling down for another 6.7s
14:02:50 INFO   pool lookup failed net=solana pool=HMzvsEEm...: cooling down for another 5.7s

The transient throttle becomes a permanent-sounding absence across three hops:

  1. condor/pool_data.py:2275fetch_pool_by_address catches every exception, logs at INFO, return None. GeckoRateLimited is now indistinguishable from "no such pool".
  2. condor/web/routes/dex.py:301if not pool: raise HTTPException(404, "Pool not found").
  3. frontend/src/pages/DexPool.tsx:234if (poolError || !pool) renders No pool at …, whose only action is "Back to pools".

The docstring on that endpoint makes the collapse explicit:

"404 when the pool is genuinely unknown or the lookup failed: either way the workspace has nothing to draw, and the two are indistinguishable to the user."

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:

  • fetch_pool_by_address re-raises GeckoRateLimited instead of flattening it to None
  • dex.py maps it to 503 with Retry-After, keeping 404 for a genuinely unknown pool
  • DexPool.tsx renders a distinct "rate-limited, retrying in Ns" state instead of "No pool at …"

@fengtality

Copy link
Copy Markdown
Contributor

remove this section from the LP pane. The warning / pool address (overridden) are confusing. Just show the pool address below the LP heading.

Screenshot 2026-08-17 at 5 11 45 PM Screenshot 2026-08-17 at 5 11 58 PM

@fengtality

Copy link
Copy Markdown
Contributor

If a user enters a pool address and a pool is found, the "No pools found" message is misleading. Only show this message when no pool is found for the address.

Screenshot 2026-08-17 at 5 26 37 PM Screenshot 2026-08-17 at 5 28 09 PM

cardosofede and others added 4 commits August 18, 2026 18:17
…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.
@fengtality
fengtality merged commit 0eabeb9 into main Aug 18, 2026
4 checks passed
fengtality added a commit that referenced this pull request Aug 18, 2026
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
fengtality added a commit that referenced this pull request Aug 18, 2026
- `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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Under Review

Development

Successfully merging this pull request may close these issues.

4 participants