feat(performance): executor performance as a first-class read surface, plus an 18-item backlog burndown - #226
Conversation
| sampled = self._sample_by_interval(history, interval_minutes, self.grain_minutes) | ||
|
|
||
| has_more = len(sampled) > limit if limit else False | ||
| if has_more: | ||
| sampled = sampled[:limit] |
There was a problem hiding this comment.
Sampling breaks pagination termination
When snapshots are missed, delayed, or unevenly spaced, the fixed over-fetch multiplier can yield at most limit sampled rows even though additional matching rows remain, causing has_more=false and next_cursor=null to silently truncate performance history.
Knowledge Base Used: Database models and repositories
| start_time: Optional[str] = Query(default=None, description="ISO 8601 start of the window"), | ||
| end_time: Optional[str] = Query(default=None, description="ISO 8601 end of the window"), | ||
| interval: str = Query(default="5m", pattern="^(1m|5m|15m|30m|1h|4h|12h|1d)$"), | ||
| limit: int = Query(default=100, le=1000), |
There was a problem hiding this comment.
Invalid limits bypass result caps
When a client supplies zero or a negative limit, the value reaches implementations that handle it inconsistently: zero disables the executor latest SQL cap and makes executor history fetch a default-sized page, while controller latest returns no rows; negative values can also produce invalid or backend-dependent SQL limits.
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
`GET /performance/history` serves controllers and executors in one row shape (hummingbot/hummingbot-api#226). This is the Condor side of it: a raw-call fetcher, the wire model, the proxy route and a capability probe. The call is raw rather than through the SDK because `hummingbot-api-client` is pinned to a released PyPI version and has no `performance` router — the design's alternative of adding one upstream-of-the-pin is not available for a version pin at a package index. It uses the idiom `condor.backtesting` already established: reach for the router's session and base_url, degrade for a client shape that exposes neither. Three failures are kept apart, because the browser draws something different for each. A 404 means this API predates the route — the ordinary case for every server on the published image — so it answers 200 with `supported: false` and the client falls back. A 400 is the caller's own mistake (a filter aimed at the wrong population) and is forwarded as one; reported as an offline server it would route the browser to a fallback and hide the bug. No answer at all stays `server_online: false`. The probe is cached with the other per-server data at a half-hour TTL, so it costs one request per server rather than one per chart, and it distinguishes "asked, and it is not there" from "could not ask" — a server that was merely down must not have a fallback pinned to it for the whole TTL after it comes back. Two mappings are read, never re-derived: `cum_fees_quote` stays None for a controller because `PerformanceReport` has no fees field and unknown is not zero, and a `POSITION_HOLD` close keeps its PnL unrealized because the position went to `position_holds` and counting it twice is the bug the upstream mapping avoids. The existing controller-performance routes are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxmWTzjHe55kZ6eSCfELT8
Two findings from verifying this branch against a live Condor clientBoth found while exercising the new 1. The executor sampler drops whole executors, not just points
Site: ReproductionThree executors, five snapshot rows, all inside a five-minute span: INSERT INTO executor_performance_snapshots
(timestamp, executor_id, executor_type, account_name, connector_name,
trading_pair, controller_id, status, close_type, is_terminal,
net_pnl_quote, net_pnl_pct, cum_fees_quote, filled_amount_quote)
VALUES
(now() - interval '5 min', 'exec-A', 'position_executor', 'acct', 'binance', 'BTC-USDT', 'ctrl-1', 'RUNNING', NULL, false, 1.50, 0.0015, 0.20, 1000),
(now() - interval '4 min', 'exec-A', 'position_executor', 'acct', 'binance', 'BTC-USDT', 'ctrl-1', 'RUNNING', NULL, false, 3.25, 0.0032, 0.40, 2000),
(now() - interval '3 min', 'exec-A', 'position_executor', 'acct', 'binance', 'BTC-USDT', 'ctrl-1', 'TERMINATED', 'TAKE_PROFIT', true, 4.75, 0.0047, 0.55, 2500),
(now() - interval '2 min', 'exec-B', 'position_executor', 'acct', 'binance', 'ETH-USDT', 'ctrl-1', 'RUNNING', NULL, false, -0.80, -0.0008, 0.15, 900),
(now() - interval '1 min', 'exec-C', 'lp_executor', 'acct', 'meteora', 'SOL-USDC', 'ctrl-2', 'TERMINATED', 'POSITION_HOLD', true, 2.00, 0.0020, 0.30, 1500);Then read the route at each interval:
At the default interval, two executors and two trading pairs are absent from a 200 response. Cause@staticmethod
def _sample_by_interval(history, interval_minutes, grain_minutes):
if not history or interval_minutes <= grain_minutes:
return history
sampled = []
last_sampled_time = None # one cursor for the whole result set
for item in history:
item_time = datetime.fromisoformat(item["timestamp"].replace('Z','+00:00'))
if last_sampled_time is None:
sampled.append(item)
last_sampled_time = item_time
else:
time_diff = (last_sampled_time - item_time).total_seconds() / 60
if time_diff >= interval_minutes: # no executor_id in this decision
sampled.append(item)
last_sampled_time = item_time
return sampledBecause the cursor is global, the interval acts as a rate limit on the merged series rather than on each executor's own series. The executor that happens to own the newest row in a window survives; the rest become indistinguishable from executors that never reported. The collision rate follows from the write cadence: executors are snapshotted every 60s ( Who is affectedCondor's dashboard is not affected: Condor's HTTP API is affected. Worth noting the route's docstring warns that The same shape exists in the controller sampler and is already documented as upstream behaviour in Condor's 2. A
|
| source | realized | unrealized | net |
|---|---|---|---|
API /performance/history row |
0.00 | 2.00 | 2.00 |
| Condor scope panel header | +$2.00 | +$0.00 | +$2.00 |
Both readings are defensible. models/performance.py treats POSITION_HOLD as unsettled on purpose — the close hands the position on to position_holds, so counting it as realized would double-count it, the same exclusion ExecutorRepository.get_performance_report applies. Condor's panel reads the executor record, where a TERMINATED executor's PnL is realized by definition.
The reason this belongs on this PR: before FEAT-087 there was no API-side realized/unrealized split for an executor, so there was nothing for the panel to contradict. They now sit one above the other on the same screen — the header above, and the chart it feeds below.
Someone has to own which one is the fact. The API's reasoning looks the sounder of the two, which would make the fix a Condor-side change — but it needs deciding rather than leaving both on screen.
Caveat on this one: it was observed against seeded rows (is_terminal = true, close_type = 'POSITION_HOLD', status TERMINATED). A real held LP position does terminate that way, so I believe the fixture is faithful, but it is worth confirming against a real one before acting.
|
@david-hummingbot thanks — both confirmed, fixed in 1c9ca97. 1. The sampler dropped whole executorsFixed as diagnosed: Your repro, replayed against the fixed sampler:
I took the controller sampler too, rather than leaving it as documented upstream behaviour. It is the same defect in the same shape, Five tests added, since your point that nothing in the code or its tests said the unnarrowed query was unsafe was the part worth fixing permanently: a fleet survives One residual I documented rather than fixed: per-scope cursors do not survive a page boundary, because Your note about the docstring landed too. "A floor, not a guarantee" was indeed a claim about resolution only, and a reader had no reason to read scope-completeness into it. It now says explicitly that sampling is per scope and that an unnarrowed query over a fleet returns the whole fleet at every interval, at a lower resolution. 2.
|
…esults The service keeps one BacktestingEngineBase so its data provider caches downloaded candles across runs, but run_backtesting builds the run on that shared instance -- time window, controller, resolution, per-run accumulators -- and then suspends for seconds on the historical candle download. Two runs interleaving there resumed against each other's state and returned silently wrong numbers, with no exception: POST /backtesting/run and POST /backtesting/tasks share the app-level singleton and submit_task spawns unbounded background tasks, so this needed no unusual load. Hold an asyncio.Lock across the whole run_backtesting await, following the guard pattern in MarketDataService.fetch_connector_tickers. The candle cache is preserved, unlike a fresh per-run engine. The controller-config classmethods stay outside the lock, being stateless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_sync_orders_to_database` awaited `get_order_by_client_id` inside the loop over `connector.in_flight_orders`, so every 60s sync cost one sequential round trip per open order — plus a flush per order whose status moved — while holding a pooled connection for the duration. Add `OrderRepository.get_orders_by_client_ids`, a batched `.in_()` sibling of the single lookup (chunked at 500 ids), fetch the connector's rows once, index them by client order id and iterate in memory, flushing once only if a status actually changed. Terminal orders are still popped from `in_flight_orders` after the session closes, and orders with no DB row are still skipped. Also whitespace-only fixes in `order_repository.py` so the repo's flake8 hook accepts the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_handle_log pruned its dedup cache by walking the entire _processed_messages dict on every incoming log line, making the cost of ingesting bot logs quadratic in the log rate on the same consumer coroutine that handles heartbeats and controller performance reports. Entries are inserted in non-decreasing timestamp order, so expiry only needs to touch the oldest end: _processed_messages is now an OrderedDict that is popped from the front while the oldest entry is expired, which is O(expired) instead of O(cache size). A _max_processed_messages cap with FIFO eviction also keeps the cache bounded, mirroring the maxlen deques already used for _bot_logs / _bot_error_logs. Also drops two dead local assignments and trailing whitespace in the same file so the pre-commit flake8 hook passes on it; no logic changed there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pt YAML
SEC-058. create_hummingbot_instance re-reads `controllers_config` from the
deployed script config on disk and joined every entry into a source and a
destination path with no validation. That YAML is attacker-controlled at the
trust boundary (POST /scripts/configs/{name} writes an arbitrary dict), so an
entry like `../../../.env` copied the API's own env file into bots/instances/.
The SEC-044 validators only ever ran on the request body, never on this list.
Each entry is now checked as a safe single path component with the same
validator the deploy request bodies use, and both resolved paths are run
through _ensure_contained against bots/conf/controllers and the instance's
conf/controllers. Failing entries are skipped with a warning instead of
aborting the deployment, matching the existing not-found branch.
_validate_safe_config_name is renamed to validate_safe_config_name so the
service layer can reuse it without reaching into a private name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…body code The pre-flight `if not await gateway_client.ping(): raise 503` was copy-pasted into 39 route handlers across four routers (32 inline, plus 7 going through a private `_require_gateway` helper in gateway_amm.py). A cross-cutting precondition expressed 39 times cannot be changed centrally — caching the ping or adding a Retry-After was 39 edits — and any new Gateway route silently shipped without it. `deps.py` now owns it as `require_gateway_online`, attached per route with `dependencies=[Depends(require_gateway_online)]`. Per route, not per router: the container-control routes (/gateway/start, /stop, /restart, /logs, /status) must answer precisely when Gateway is down, and the DB-backed reads (/clmm/pools, the position and swap searches, the event feeds) keep serving stored data regardless. Those 22 routes stay unguarded, and /clmm/positions/search keeps treating a failed ping as "skip the refresh" rather than an error. The `except HTTPException: raise` clauses are deliberately left in place: most handlers still raise their own 400/502 inside the try, and for the rest the clause is cheap insurance against the next in-try raise being rewritten into a 500. One behavioural note: the guard now runs before request-body validation, so a malformed body sent while Gateway is down answers 503 instead of 422. Also wraps two over-length signatures in routers/accounts.py (delete_credential, add_credential) that predate this change — format only, no logic — because the pre-commit flake8 hook lints the whole staged file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gateway names a write's transaction id three ways — `signature` on Solana, `txHash` on EVM, `hash` on older shapes — and that extraction was copy-pasted into seven handlers across the CLMM, AMM and swap routers. Six copies read all three keys; the CLMM open handler's copy read `signature` alone, so a uniswap or pancakeswap open that Gateway had confirmed came back as a 500 saying no transaction signature was returned, for a position that had just been opened on-chain. The AMM event recorder held a third variant: it read `signature` and `txHash` but not `hash`, and its log line read `signature` alone, so every EVM AMM write was logged as "Recorded AMM ADD_LIQUIDITY: None". get_transaction_hash_from_response now lives in routers/gateway_extras.py beside get_transaction_status_from_response and transaction_id_from_error — the success and failure paths for the same question — and all seven call sites go through it. The AMM recorder keeps its "" fallback, since it records what it has rather than failing a write whose liquidity has already moved. The other two halves of ARCH-054 — the duplicated status parser and the diverging chain->gas-token map, whose ternary wrote gas_token NULL on every chain outside solana/ethereum — were already consolidated into gateway_extras.py and gateway_client.py by earlier work, but nothing pinned them. test_gateway_response_ parsing.py now does: AST guards that each parser has exactly one definition, that only gateway_client.py holds a chain-keyed gas-token dict, and that every gas_token write resolves through get_native_gas_token; plus a functional test driving /clmm/add, /clmm/remove and /clmm/collect-fees on base-mainnet and asserting all three persist the same "ETH". Reintroducing either the ternary or the signature-only open fails four of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An executor that closed in milliseconds could sit at status=RUNNING forever. create_executor starts the executor synchronously and only then awaits _persist_executor_created, so the 1s control loop can see is_closed and write the completion first. That write went through update_executor, which is select-then-update and silently no-ops when the row is missing -- while the caller logged success -- and the creation INSERT then landed afterwards with RUNNING. The same no-op swallowed the final state whenever the creation INSERT failed at all, since _persist_executor_created catches every exception; that executor never appeared in search or the performance report, and cleanup_orphaned_executors could not repair a row that did not exist. Add ExecutorRepository.upsert_executor_completion, the insert-or-update shape upsert_position_hold already uses: it updates the row, or inserts it from the metadata the caller carries, in a SAVEPOINT that tolerates a creation INSERT winning the race in another session. _persist_executor_completed calls it and logs when a row had to be repaired; _persist_executor_created now treats a duplicate INSERT as the benign end of the race rather than an error, so it can no longer resurrect a closed executor to RUNNING. Deliberately not reordering the two writes: _instantiate_and_register raises HTTPException(400) when instantiation fails, which would commit a RUNNING row for an executor that never started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndle feeds get_candles_feed tested `feed_key not in self._candle_feeds`, then awaited _validate_pair (an exchange-data load plus a REST candle probe, hundreds of milliseconds) before starting the feed and registering it. Two concurrent first-touch callers for the same key both passed the guard, both started a feed, and the loser of the assignment kept a live exchange subscription with no reference in _candle_feeds -- so stop_service, stop_candle_feed, manually_cleanup_feed and _cleanup_unused_feeds, which all key off that dict, could never stop it. The /candles and /historical-candles routes and the websocket subscribe path make that collision routine. Guard the create-and-register section with a per-feed_key asyncio.Lock and re-check membership inside the lock, using the same setdefault idiom already used for _ticker_locks in this file. The lock entry is pruned alongside the feed in all four teardown paths, skipping a lock that is still held so the next caller is not handed a fresh one mid-creation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PnL row `get_performance_report` ran a second, unlimited `SELECT net_pnl_quote` over every completed executor purely so `ExecutorService` could take a mean and a variance in Python. The list had exactly one consumer -- the Sharpe ratio -- so the report scanned and transferred the whole executors table, and the `/ws/executors` performance push loop polls it every `update_interval` seconds per subscriber. At 100k completed executors that was a 100k-row fetch twice a second per connected client. The count, the sum and the sum of squares give the sample standard deviation directly, so the second moment joins the aggregate query that already computes sum/avg/count/win rate over the same filter. The repository now returns `pnl_std` and `completed_count` instead of `pnl_values`, and the service divides one by the other. Row count is O(1) in the table size. The moments are subtracted in Decimal rather than float: a large mean with a small spread otherwise loses its whole variance to cancellation. That is also what Postgres does inside `stddev_samp` for a numeric column, which the item proposed -- deriving it here instead keeps the SQL runnable under the in-memory SQLite the repository tests use, where `stddev_samp` does not exist. `sharpe_ratio` is unchanged: still null below two completed non-POSITION_HOLD executors and when every PnL is identical, still the same number otherwise. Closes PERF-062. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stop-and-archive endpoint built three aliases of the same string —
actual_bot_name, container_name and bot_name_for_orchestrator — from the
path parameter with no intervening mutation, so the "bot found" check
OR'd a membership test with itself and the orchestrator applied the three
provably identical names inconsistently across its 8 steps. The prefix
handling this was left over from is gone: docker_service creates the
container as `name=instance_name` under bots/instances/{instance_name}.
Collapse them to a single bot_name in both the router and
BotsOrchestrator.stop_and_archive_bot, matching how the neighbouring
/stop-bot endpoint already passes the name through unmodified.
Response-contract change: the `details` object of both responses now
carries one `bot_name` key in place of `input_name`, `actual_bot_name`
and `container_name`, which always held the same value.
Adds tests pinning that the name used for every destructive step (stop
container, archive directory, remove container, mark archived) is the
path parameter verbatim, plus the not-found branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_authenticate_websocket` fell back to `?token=base64(user:pass)` and to bare `?username=`/`?password=` when no Authorization header was present. uvicorn logs the full path *with* its query string for every websocket handshake, so those channels wrote the single global admin credential pair into access logs, logfire traces, reverse-proxy logs and browser history — one INFO line was a full disclosure. Credentials are now read from headers only. Browsers cannot set headers on `new WebSocket(...)`, so before removing the query-param branches this adds the channel they can use: `Sec-WebSocket-Protocol: hummingbot-auth, base64url(user:pass)`, with the selected subprotocol echoed back in `accept()` (a browser fails the connection if the server does not echo it). base64url is the only base64 variant whose alphabet is a valid subprotocol token; standard base64 is still accepted on both channels. Both endpoints also accepted the handshake *before* authenticating, so an unauthenticated peer always reached an open socket and was only then closed with 4001. Authentication now runs first and a failure refuses the handshake itself, with an HTTP 401 + `WWW-Authenticate: Basic` denial response where the ASGI server supports the extension and a 1008 close where it does not. Note: this is a breaking change for any client passing credentials in the URL, as the item specifies — the subprotocol channel is its browser-side replacement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rable The /ws/executors clamp used three module literals (0.5 / 60.0 / 2.0) in services/executor_ws_manager.py, so MARKET_DATA_WS_* only ever reached /ws/market-data and the operator knob silently did nothing for half the WebSocket surface. Add executor-specific fields on MarketDataSettings and read them inside _clamp_interval, the way WebSocketManager._clamp_interval already does. The executor floor stays stricter than the market-data one (0.5 vs 0.25): executor push loops poll the database, not in-memory candles and order books. Defaults are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The executor WebSocket manager carried seven structurally identical `_*_push_loop` coroutines: fetch, hash, send-if-changed, sleep, swallow CancelledError. Only the fetch expression, the frame `type` and at most one derived key (`total_count`, `bot_count`) differed, so every change to the polling contract had to be made in seven places. Collapse them into a single `_push_loop` driven by a `sub_type -> PushSpec(fetch, msg_type, extra)` table, with the per-type payload shaping moved into `_fetch_*` wrappers that also normalise the mixed sync/async service calls. `_logs_push_loop` stays separate: it keys on `last_log_count`, not on a payload hash. With one place left to edit, add what the sibling `services/websocket_manager.py` already does and these loops did not: a disconnected client now ends the loop instead of logging a push error every interval. The guard wraps only the send — a `RuntimeError` out of a fetch is a service fault, not a dropped client, and stays a logged-and-retried error as before. Frame contents, key order and change detection are unchanged. The only behavioural difference on the wire is that a dropped client stops the loop, and the `executor_summary` error log now says `executor_summary` rather than `summary`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run_backtesting is awaitable, but past the candle download it is an uninterrupted
CPU loop over every candle: awaited inline it executed on the API's only event
loop thread. A single slow run pinned that thread at 100% for hours -- every
endpoint, /docs included, stopped answering, the container still looked healthy
to every liveness check, and only a restart cleared it. DELETE
/backtesting/tasks/{id} reported CANCELLED and changed nothing, because asyncio
delivers a cancellation at an await and the simulation offered none.
Each run now executes in a spawned worker process, supervised from the loop by a
short poll. The loop stays free, a cancellation lands within a poll interval and
signals the worker dead -- the only thing that stops a wedged CPU loop -- and a
run that overruns its wall-clock budget is terminated and reported as failed
instead of burning a core forever. A semaphore caps how many runs are in flight
so a burst queues rather than claiming a core each. The outcome crosses back
through a file rather than a pipe: a result is megabytes and a pipe holds tens of
kilobytes, so a child blocking in send() against a parent waiting for it to exit
would deadlock.
This also subsumes CORR-060. That lock existed because one shared
BacktestingEngineBase is mutated in place by each run and then suspends on the
candle download, so overlapping runs returned silently wrong numbers. A worker
process owns its engine outright, which makes that state unshareable by
construction rather than by discipline, and the lock is gone. Its regression
tests are kept and rewritten against the process mechanism, plus the cap. The
trade-off CORR-060 documented stands: a per-run engine loses the per-instance
candles cache, so candles are downloaded once per backtest -- a shared cache is
the follow-up.
Verified against a real 3-day BTC-USDT backtest: results, processed_data,
position_holds and pnl_timeseries are byte-identical to the inline path, and only
the randomly generated executor id differs, as it does between any two runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… through The two gateway routers owned their own database sessions: 15 `get_session_context` blocks across 1409 + 525 lines, each constructing a repository inline, hand-building the row dict, and carrying its own copy of the "log the write failure but don't fail the trade" policy with a different message every time. The persistence rules for a CLMM position lived only inside HTTP handlers, which is why the transaction poller's auto-discovery path had to re-derive them with a divergent key set, and why none of it could be tested without FastAPI. GatewayCLMMService and GatewaySwapService now own that, constructed once in main.py next to TradingHistoryService and injected through deps. Both sit on a RepositoryService base that splits the scaffold in two on purpose: `_in_repo` propagates, so a read that must answer 404 or 500 still can, and `_in_repo_best_effort` is the one place the swallow policy is written. Handlers reduce to: call Gateway, hand the service what happened, answer the caller. Nothing about the rows changes. test/test_gateway_router_persistence.py drives the real routes and pins every column of the position, event and swap rows each handler writes, both bookkeeping paths (CONFIRMED books, SUBMITTED and FAILED do not), the two read envelopes the handlers used to assemble by hand, and the 404-vs-500 distinction that a swallowing helper would have erased. `_refresh_position_data` moved with the sessions it needed and now takes the gateway client rather than the whole AccountsService. ARCH-053's `require_gateway_online` guard is untouched on all 39 routes; ARCH-054's parsers stay in routers/gateway_extras.py, with `_record_failed_write` left in the router as a shim over the single `transaction_id_from_error` — services/ still imports nothing from routers/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An executor's row was written exactly twice: zeros at creation, the real figures at completion. So a RUNNING executor's row said its PnL was zero for its entire life -- the live numbers existed only in ExecutorService memory, which is why get_executors and get_performance_report merge _active_executors over the database on every read. Two things followed. There was no series to chart, and worse, an API restart destroyed the accounting of every executor that was live: cleanup_orphaned_executors flipped each RUNNING row to TERMINATED/SYSTEM_CLEANUP and touched none of the PnL columns, so the creation-time zeros became the permanent record and /executors/performance summed them forever. executor_performance_snapshots is written by ExecutorService from _control_loop on its own interval (60s by default -- a position executor can live three minutes and would get one point at the controllers' 5-minute grain), plus one is_terminal row at completion in the same transaction as the record update. The terminal row is what makes a closed executor's series a single-table query: no join to executors, no "and then append the final value" rule for every future reader, and no exposure to the two paths that leave that record at zero. It sits behind a SAVEPOINT because the coupling has a direction: the record update is the accounting, the snapshot is a point on a chart, so a failing snapshot rolls back only itself. The reap then adopts the last snapshot, which is what turns this from a chart into the accounting no longer being wrong. An executor with no snapshot keeps today's behaviour, and the row stays SYSTEM_CLEANUP so an approximated close is still visible as one. Typed columns rather than the controller table's JSON blob: ExecutorInfo is named field by field all over this repo, unlike the core-versioned PerformanceReport, and typing it keeps fill_events and grid levels out of a per-minute row. There is no second volume column -- filled_amount_quote is the volume traded on every executor type, including LP. PERFORMANCE_RETENTION_DAYS defaults to 0, which deletes nothing: an upgrade must not start deleting an operator's history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FaWh5TSqusPXeAefzzJjfC
GET /performance/history?subject=controller|executor returns both populations under one normalized row shape, paged and cursored identically, so a consumer writes seriesFor(scope) once instead of maintaining two clients. Two URLs returning the same shape would only move the branch from a parameter to a path and buy nothing; the cost is the ~6 lines of explicit 400 for a filter aimed at the wrong population, which FastAPI cannot express and which would otherwise be accepted and silently ignored. The two subjects are stored differently on purpose -- the controller's payload is an opaque, core-versioned PerformanceReport that has to be blobbed, the executor's is typed columns -- because what has to be symmetric is the response, not the storage. The normalization is additive rather than lossy: everything the report carries with no executor counterpart (open_order_volume, inventory_imbalance, positions_summary, close_type_counts) stays reachable in the performance passthrough, so a client migrating off /bot-orchestration/controller-performance-history loses nothing. cum_fees_quote is null for controllers rather than zero, because PerformanceReport genuinely has no fees field and unknown is not the same as measured-and-empty. subject=controller goes through BotsOrchestrator.get_controller_performance_history unchanged -- the same call the existing route makes -- so the two share one query path by construction and cannot drift. The two existing controller routes and ControllerPerformanceRepository are untouched: they are consumed externally, and this is new surface only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FaWh5TSqusPXeAefzzJjfC
The design doc moves to features/done/ with its commits annotated, its implementation-plan steps and acceptance criteria marked, and the three deviations from the design recorded: the terminal row sits behind a SAVEPOINT so a failing snapshot cannot roll back the completion, a snapshot is skipped rather than zeroed when executor_info cannot be read, and the retention sweep runs on its own hourly guard inside the snapshot tick. Also notes what verification found: the suite needs asyncio_mode=auto, which is configured nowhere in-tree, and the env's pytest/pytest-asyncio pair is incompatible, so 82 async tests fail before any change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FaWh5TSqusPXeAefzzJjfC
/performance/history gave a consumer one series call for both populations, but it still had to fall back to /bot-orchestration/controller-performance-latest for the live tiles -- and had nothing at all for executors. This adds /performance/latest as the second half of that pair, so latestFor(scope) is written once and a consumer can leave the bot-orchestration surface entirely. - ExecutorPerformanceRepository.get_latest: the last snapshot of every matching executor. The filters narrow the grouped subquery instead of the join, so the whole table is not aggregated and then discarded. Newest-first with a limit, because every executor that ever ran leaves a terminal row behind and the population grows without bound -- the live ones belong at the top. - A closed executor's latest row IS its terminal row, so "its final value" and "its current value" are one query, with is_terminal and close_type on it. - The controller subject goes through get_latest_controller_performance, the same method the old route calls, so the two cannot drift. controller_id and the limit are applied in the route: the result is one row per (bot, controller) and small by construction. - Both routes share one _reject_foreign_filters helper, so they cannot disagree about which filter belongs to which population. A malformed controller timestamp sorts last rather than 500ing a whole dashboard. Reads the stored series, not memory: this row and the last row of /history are the same row, by design. Live figures stay on /executors/. The old routes are untouched and wire-compatible; this is new surface only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aemK7XRzg7bMuzDTd2fQb
Two suites that were written alongside the fixes they cover but never landed with them. - test_bot_runs_payload: final_status is ~99% of a bot run record's bytes, so the list endpoint must omit it while the detail endpoint keeps it. - test_ticker_sources: ascend_ex and okx_perpetual both report BASE volume in a field that used to be stored as quote volume, which made cross-exchange liquidity comparison meaningless. These are regression assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aemK7XRzg7bMuzDTd2fQb
FEAT-001 replaced only half of the surface it set out to replace. /bot-orchestration/ carries two controller routes and /performance/history answered only -history, so a consumer could migrate its charts but still had to call the old route for current values -- not a migration a client can finish, which was the objective. The addendum records /performance/latest closing that, and the design calls behind it: newest-first with a limit because every executor that ever ran leaves a terminal row and the population grows without bound; filters on the grouped subquery rather than the join; the controller branch reusing get_latest_controller_performance so old and new cannot drift; and no pagination block, because one row per scope is not a series. Also records what a full migration still needs, since the doc is where the next reader will look for it: the old routes stay wire-compatible and consumers move when they choose, subject=executor covers only in-process executors and never a Docker bot's, and PERFORMANCE_RETENTION_DAYS still defaults to keep-forever. Verified against the deployed API: /latest's row and the newest row of /history come back byte-identical, which is the property the design turns on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQvZV28iuCWGAyCfYp5kT5
Both directories are working notes for the /improvements and /design-feature burndown runs, not repo content. improvements/ was already ignored but features/ was tracked; untrack it and ignore both consistently. The files stay on disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012FvsHXDNi7i7gJ7ogLdAGC
stop_and_archive_bot returned silently when the bot process refused to stop and when the container was still alive after every retry, leaving the run row stopped-but-not-archived with no error_message while the caller had already been told the background task started fine. Both paths now close the run out through a new mark_bot_run_errored helper, which also absorbs the two error blocks that already did this inline (container removal failure and the outer exception handler). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
`get_active_orders` ended with a hardcoded `.limit(1000)`, so an account with a larger active book silently started up missing its oldest orders: they were invisible to order sync and reconciliation while still live on the exchange. The bound is now an explicit `limit` parameter defaulting to the previous 1000 (other callers keep their behaviour), `_load_existing_orders` passes `limit=None` for the complete book, and a query that reaches its cap logs a warning naming the count and the limit so truncation is never silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
get_performance_report wrapped its whole database block in a bare except Exception, logged, and returned the zeroed report it had already built. An unreachable database was therefore indistinguishable from "no executors yet": the route answered 200 with zeroes and the /ws/executors performance channel pushed a confident row of them to dashboards, with no error state and nothing marking them stale. The failure now propagates. The route already turns it into a 500, and the shared push loop sends one error frame naming the channel and the subscription before it goes back to retrying quietly -- and clears the payload hash so the recovery frame is sent even when the report comes back byte-identical. Zeroes are left to mean an empty dataset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
Closing a CLMM position awaited asyncio.sleep(2) — the wait for the close transaction to propagate — inside the open database session, so a pooled connection sat idle for two seconds per close while the database had nothing to do. A fleet closing several positions at once could drain the pool that way. Split the unit of work around the wait: one session books the CLOSE event and the final fees, then it is released; the propagation wait and the Gateway re-read happen with no connection held; a second short session marks the row CLOSED. Same rows, same values, same order, and a write failure is still swallowed rather than reported as a failed close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
reconcile_active_orders called update_order_status once per tracked order, so each order cost its own SELECT plus a flush inside a savepoint. Read the whole tracked book with a single get_orders_by_client_ids (chunked at 500), index it by client order id, mutate in memory and flush once, and only when a status or note actually moved -- the same shape PERF-049 gave the sync path. Tracking is now updated after the transaction commits, so a failed write leaves every order tracked for the next startup instead of silently dropping it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
get_executor_stats issued seven statements for a payload that is entirely aggregates: four unfiltered scalars (two COUNTs, two SUMs, none of them narrowing anything) and three separate GROUP BY passes over the same table. The four scalars share a single filter -- none -- so they fold into one aggregate row, with the RUNNING count as a SUM(CASE) beside them. The three breakdowns become one statement grouped by (executor_type, status, connector_name) and pivoted back into the three dicts in Python; the grouped row count is bounded by the distinct combinations, not the table size. Same aggregate shape get_performance_report was reduced to next door. The returned dict is unchanged, which the new test pins against the seven-query version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
require_gateway_online runs ahead of 39 routes and each run was a full HTTP round-trip to Gateway. Cache the ping verdict in GatewayClient for a 2s TTL, so a burst of guarded requests costs one round-trip, and clear it whenever a Gateway call fails to connect (or its mTLS certs are missing) so an outage mid-TTL is never masked by a stale "available". The 503 now carries Retry-After set to that TTL; the detail string and its single source are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
POST /docker/remove-container/{name} refused any container whose name did not
start with "hummingbot-". Nothing has named bot containers that way since the
prefix was dropped: create_hummingbot_instance passes the instance name to
Docker verbatim. The guard therefore rejected exactly the containers this API
creates, while accepting the infrastructure containers that do carry the
prefix (hummingbot-postgres, hummingbot-broker) and then trying to archive a
bots/instances directory that never existed for them.
Replace it with the invariant the endpoint actually needs: a container is
removable here only if it owns the bots/instances/<name> directory this
endpoint archives. That accepts every API-created bot whatever its name,
refuses unrelated host containers, and - via the existing containment helper,
now exposed as DockerService.resolve_instance_dir - refuses a name that
escapes bots/instances before anything is removed or archived.
Also satisfies the repo's pre-commit gate on the touched file: isort ordered
its imports and the trailing whitespace / long signature flake8 flagged are
cleaned up. Whitespace only, no behaviour change.
Closes READ-115.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
A subscribe message carrying {"update_interval": "fast"} reached the
clamp's max/min comparison and raised TypeError out of handle_subscribe,
surfacing as an unhandled exception rather than the _send_error response
every other malformed-input path returns.
_clamp_interval now raises ValueError for anything that is not a real
number (bool excluded, being an int subclass that would silently clamp
to the floor), and handle_subscribe turns that into the usual error
frame naming the offending field without creating the subscription.
Valid numbers, a missing interval and the configurable bounds are
unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
ARCH-109. The AMM and archived-bots routers were the last two that still opened their own `get_session_context` blocks, so the convention was inconsistent in precisely the two files a newcomer is most likely to copy from. `GatewayAMMService` sits on the `RepositoryService` base that ARCH-052 introduced for exactly this, alongside `GatewayCLMMService` / `GatewaySwapService`: reads propagate (`_in_repo`), and the "record it, but never fail a write that already happened on-chain" policy is expressed once (`_in_repo_best_effort`) instead of four times. The event row shape, the DAMM v2 position bookkeeping and both pagination envelopes move with it. The router keeps only the parsing of Gateway's response — the amount keys differ per event type, and `transaction_id_from_error` stays in `routers/gateway_extras.py` so `services/` never imports from `routers/`. The single block in `archived_bots.py` goes to `BotsOrchestrator`, which already owns bot-run persistence since ARCH-035, rather than to a new service for one call. Behaviour-preserving: `test/test_amm_router_persistence.py` pins every column both AMM paths write, both envelopes (including that the clamped query still echoes the limit as asked), and the swallow policies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
The generated .env is the only place an operator sees a knob, and it comes from a hand-maintained heredoc in setup.sh that nothing derives from config.py. Five of the nine settings groups had drifted out of it entirely: PERFORMANCE_, BACKTESTING_, MARKET_DATA_, CORS_ and AWS_. The sharpest case is PERFORMANCE_RETENTION_DAYS, whose default of 0 keeps every snapshot forever -- an operator who does not know the variable exists cannot discover that their database grows without bound, nor cap it. Write each of the five groups into the heredoc commented out with its config.py default and description, so a fresh .env documents the knob without pinning a value config.py owns; the active assignment set is unchanged, so a fresh .env produces byte-identical runtime settings. Point the README's curated excerpt at config.py as the authoritative list. Pin the template against config.py in test: every prefixed group must be represented, every documented setting must exist, and every default shown must be the one config.py applies -- so the next group added cannot drift out of the template silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
The transaction poller built GatewayCLMMRepository / GatewaySwapRepository itself in six places, so it owned a second write path into tables the /gateway/clmm and /gateway/swap routes also write. The two paths had drifted: a position the discovery sweep found carried lower_bin_id, upper_bin_id and the pending fees; one the open route recorded carried position_rent and none of those. The same logical position had two different row shapes in one table, and anything reading it back had to know which writer it was looking at. build_position_row is now the only thing that decides what a new gateway_clmm_positions row contains, and its key set is the union of the two: a caller that cannot know a value passes nothing and gets NULL (bins the open route never learns, rent no discovered position locked through us), and zero only where zero is the fact. Discovery keeps everything only the chain can tell it. The poller reads its pending book and its open positions as plain dicts and writes through the services, which also ends the session it used to hold open across a whole cycle's worth of Gateway calls. Its own decisions are untouched: the availability gate, the dropped grace window, the retry age and the consecutive-miss gate all still say when a row may change, and are now pinned by tests since every call site moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
A run owns its engine in its own process, which is what makes it killable and what makes two runs unable to corrupt each other -- and it cost the per-instance candles_feeds cache, so an optimizer sweeping N configs over one market downloaded that market's full history N times. The cache goes back outside the process, holding the only thing safe to share: the candle data. No engine, provider or controller is reachable from two runs, and every reader unpickles its own copy, so nothing one run mutates can reach another. Three properties make it safe to serve. An entry is keyed by everything the download is a function of -- market, interval, max_records and the run's window -- so a hit covers exactly the range asked for and a wider window misses. An entry is only served for a bounded time, because a window ending near now is fetched with its last candle still forming. And the number of entries is capped with the least recently used dropped, so sweeping across pairs and timeframes cannot grow the store without limit. A frame upstream reused from its own in-run dict is never stored, since it was fetched for a shorter lookback than the key it would land under. Workers that miss the same key together take the entry file as a lock and re-check inside it, so raising BACKTESTING_MAX_CONCURRENT does not re-multiply the download. A cache is an optimization, never a failure mode: every read and write degrades to "download it again", and BACKTESTING_CANDLES_CACHE_ENTRIES=0 turns it off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
Both samplers thinned with one global timestamp cursor, which made `interval` a rate limit on the merged series rather than on each scope's own. An unnarrowed query interleaves every scope's rows on the same grain, so the scope owning the newest row in each window survived and the rest were not thinned but dropped -- absent from a 200 response, indistinguishable from scopes that never reported. Three executors over a five-minute span answered with one at the 5m default; a twelve-controller fleet answered with eleven at 1h. The cursor is now kept per executor_id, and per (bot_name, controller_id) on the controller side. Input order is preserved, so both series stay descending by timestamp and pagination is untouched. Per-scope cursors do not survive a page boundary -- `next_cursor` is a timestamp -- so a scope can be over-sampled by at most one row per page; it is never dropped. The controller row shape is unchanged, so /bot-orchestration/controller-performance-history stays wire-compatible; it just stops hiding controllers. Also states the two claims a reader had no way to check. `interval` was documented as "a floor, not a guarantee", which is a claim about resolution only -- it now says sampling is per scope and a fleet query returns the whole fleet. And the POSITION_HOLD split, previously reasoned about only in the source, is now in the field descriptions a consumer reads off the schema: a held executor is terminal and TERMINATED with its PnL still unrealized, because the position carries on in position_holds and counting it realized would double-count it against /executors/performance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WexGCaGpo82gU8cEAg6opn
1c9ca97 to
8f3f056
Compare
Describe the bugAfter an API crash/restart, an executor that was live at the time of the crash gets correctly reaped: However, that reap path never inserts a corresponding terminal row into The effect: This contradicts the documented design of the new performance routes ( Reproduced twice on Steps to reproduce bug
Example output
{
"status": "TERMINATED",
"close_type": "SYSTEM_CLEANUP",
"net_pnl_quote": -0.0332054,
"cum_fees_quote": 0.0314054,
"filled_amount_quote": 78.5135
}
{
"timestamp": "2026-09-08T15:04:02.974401+00:00",
"status": "RUNNING",
"is_terminal": false,
"close_type": null,
"unrealized_pnl_quote": -0.0332054,
"cum_fees_quote": 0.0314054
}Raw Note there is no third row with |
Describe the bug
By contrast, Because
This matters beyond the raw field: condor (the companion Telegram/web client) reads Reproduced twice on Steps to reproduce bug
Example outputActual stop time (step 3): Bot-run record returned in step 4: {
"bot_name": "burndown_retest1-20260908-233145",
"deployed_at": "2026-09-08T15:31:46.221226+00:00",
"stopped_at": "2026-09-08T07:32:29.817197+00:00",
"deployment_status": "DEPLOYED",
"run_status": "STOPPED"
}
For comparison, when the same bot is stopped via |
Describe the bug
Both routes go through trade_fills["cum_fees_in_quote"] = trade_fills.groupby(groupers)["trade_fee_in_quote"].cumsum()When the table is empty, the resulting DataFrame's numeric columns come back as This is a plausible real-world case, not just a synthetic one: a bot with a misconfigured order size (e.g., below the exchange's minimum notional) will have every order rejected and end up with exactly this all-failed, zero-fill state once archived. Steps to reproduce bug
Example outputConfirmed the underlying Reproduced twice on |
An executor that was live when the API crashed was reaped correctly on restart --
TERMINATED, SYSTEM_CLEANUP, and its metrics adopted from the last snapshot -- but the
reap only ever touched the `executors` table. It was the one path to TERMINATED with no
terminal row behind it, so the invariant the /performance routes are built on (a closed
executor's latest row IS its terminal row, no join back to `executors`) held for every
executor except the ones a crash caught.
/performance/latest and /performance/history kept serving those executors' last RUNNING
snapshot forever -- is_terminal false, close_type null -- while /executors/{id} reported
TERMINATED/SYSTEM_CLEANUP with the adopted PnL. Two surfaces permanently disagreeing
about whether an executor was done.
The reap now writes the terminal row the completion path writes, in the same transaction
as the record update so the two cannot disagree, and behind a SAVEPOINT for the same
reason the completion path uses one: the reap is the accounting, the snapshot is a point
on a chart, and a failing INSERT must roll back only itself rather than leave the whole
fleet RUNNING. Identity comes off the record rather than the snapshot, so an executor
orphaned before its first snapshot gets a row too -- carrying the zeros the record itself
books, with SYSTEM_CLEANUP marking the close as approximated on both surfaces.
Reported by @david-hummingbot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
update_bot_run_stopped wrote datetime.utcnow() -- correct in value, naive in type -- to a TIMESTAMP(timezone=True) column, so the driver stored it as if it were already in the session's local timezone and the row landed the server's UTC offset behind the real stop time (8 hours, where this was reported). stop-and-archive masked it: update_bot_run_archived runs afterwards with an aware value and overwrites the wrong one. A bot stopped through plain POST /bot-orchestration/stop-bot and never archived kept the skew indefinitely, and both run duration and the attribution of performance-history windows to a run are read off this field. PositionHold.last_updated had the same naive utcnow(), and while it is an in-memory field the API serializes it with isoformat() beside values read back from the database, which are aware -- so the same field reached a consumer labelled UTC on one path and unlabelled on the other. Both now use datetime.now(timezone.utc). A test walks every repository's AST for utcnow() calls, since nothing in the code or its tests said the naive call was unsafe. The trailing whitespace this file carried throughout is stripped so the lint hook can run on it. Reported by @david-hummingbot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
…l archive
An archived bot whose TradeFill table is empty -- every order rejected before it could
fill -- turned GET /archived-bots/{db}/performance and /summary into 500s with "cumsum is
not supported for object dtype". A zero-row read_sql_query gives back `object` columns,
pandas having nothing to infer a dtype from, and cumsum refuses object dtype however
empty the frame is.
Not a synthetic case: a bot whose per-level order notional sits below the exchange
minimum has every order rejected and archives in exactly this state, while /executors and
/orders on the same archive answer fine.
The scaled columns are now coerced to numeric before any arithmetic, in all three readers
that divide by 1e6 -- the trap is the same in each, and a stable dtype regardless of row
count is what the callers were already assuming. Coercion is per column because
DataFrame.apply never calls its function on a frame with no rows, which is precisely the
case at hand.
The file's unused sqlalchemy imports, trailing whitespace and two over-long lines go with
it, so the lint hook can run on it at all.
Reported by @david-hummingbot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
|
@david-hummingbot all three reproduced and fixed — 1. The reap never wrote a terminal row (
|
Describe the bug
{"detail":"Backtest worker exited with code 1 without producing a result"}This happens even for a fully valid config against a real connector — the backtest computation itself completes successfully every time. The failure is in a completely separate step: writing the result to disk. Root cause, confirmed via a full traceback: Because of this, Compounding bug: that def _worker_main(config, controllers_path, controllers_module, out_path) -> None:
try:
result = _run_backtest_blocking(config, controllers_path, controllers_module)
blob = pickle.dumps({"ok": True, "result": result}, ...)
except Exception as e:
blob = pickle.dumps({"ok": False, "error": ..., "traceback": ...})
with open(out_path, "wb") as fh: # <-- not covered by the try/except above
fh.write(blob)So instead of the worker producing a The candle-cache feature in the same PERF-112 commit already hits the identical permission error on Steps to reproduce bug
Example outputAPI response, every time, regardless of config validity: Captured server log showing the real cause: Calling ProvenanceIntroduced by PR #226. Does this reproduce when hummingbot-api itself runs in Docker?No — not this specific trigger. The $ docker run --rm -v $(pwd)/bots:/hummingbot-api/bots continuumio/miniconda3:latest \
bash -c "whoami && touch /hummingbot-api/bots/data/backtests/.write-test && echo WRITE_SUCCEEDED"
root
WRITE_SUCCEEDEDSo this exact symptom is specific to the environment it was found in: hummingbot-api run directly on the host ( That doesn't make the underlying code issue moot, though: |
Describe the bug
Root cause: connector_config = HummingbotAPIConfigAdapter(
AllConnectorSettings.get_connector_config_keys(connector_name)
)
for key, value in keys.items():
setattr(connector_config, key, value)— with no check that every field Reproduces identically across every connector tried (binance_perpetual_testnet, binance, xrpl), so it's a generic gap in the credential-save path, not connector-specific. Steps to reproduce bug
Example outputProvenancePre-existing — not introduced by PR #226. |
Describe the bug
Two independent ways to trigger this, both reproduced: 1. Nonexistent connector. curl -s -X POST ".../controllers/market_making/pmm_simple/config/validate" -d '{"id":"t","controller_name":"pmm_simple","controller_type":"market_making","connector_name":"not_a_connector","trading_pair":"BTC-USDT","total_amount_quote":100}'
# -> {"message":"Configuration is valid"}Saving and deploying this config succeeds at the API level ( The resulting 2. Human-readable enum string instead of the numeric enum. The same gap applies to any (The same Steps to reproduce bug (nonexistent connector → phantom bot_runs row)
Example outputDeploy response (falsely reports success): {"success":true,"message":"Instance qa_bogus_connector_bot-20260909-004931 created successfully.", ...}Container state seconds later: [{"name": "qa_bogus_connector_bot-20260909-004931", "status": "exited", "image": "hummingbot/hummingbot:latest"}]The permanently-orphaned bot_runs row (unchanged no matter how long you wait, or even after manually removing the exited container): {
"id": 7,
"bot_name": "qa_bogus_connector_bot-20260909-004931",
"deployed_at": "2026-09-08T16:49:31.297332+00:00",
"stopped_at": null,
"deployment_status": "DEPLOYED",
"run_status": "CREATED",
"error_message": null
}
Related: config template misreports a required field
$ curl -s .../controllers/market_making/pmm_simple/config/template | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
{"default": "PydanticUndefined", "type": "<class 'str'>", "required": false}
$ curl -s -X POST .../controllers/market_making/pmm_simple/config/validate -d '{"controller_name":"pmm_simple","controller_type":"market_making","connector_name":"binance_perpetual_testnet","trading_pair":"BTC-USDT","total_amount_quote":100}'
{"detail":"1 validation error for PMMSimpleConfig\nid\n Field required [type=missing, ...]"}The ProvenancePre-existing — not introduced by PR #226. |
Describe the bug
vs. what actually comes back: on Steps to reproduce bugcurl -s -w "\nHTTP:%{http_code}\n" -u admin:admin -X POST \
"http://localhost:8000/docker/stop-container/this-container-does-not-exist"Example outputProvenancePre-existing — not introduced by PR #226. |
…not a 200
POST /docker/stop-container/{name} answered HTTP 200 for a container that does not
exist, with docker-py's exception string as the body. Two defects in one line: a caller
that checks the status code -- which is how a caller checks -- read a container that was
never stopped as one that had been, and the body published the daemon's socket URL and
negotiated API version ("404 Client Error for http+docker://localhost/v1.55/..."), which
is a detail of how this API talks to Docker rather than an answer to anything asked.
Every container operation in DockerService shared the shape `except DockerException as e:
return str(e)`, and every route returned it verbatim, so the same 200-with-an-error-string
answer came out of start-container, clean-exited-containers and both listing routes -- the
listings documented as returning a list.
DockerService now separates NotFound from the rest, logs the raw docker-py error, and
returns the kind of failure with a message that names only the container. routers/docker.py
maps that to a status code in one place: 404 for a container that is not there, 502 for a
daemon that refused or could not be reached. A successful stop or start now says so
instead of returning null, which was itself indistinguishable from a failure body.
Failures stay in-band rather than becoming exceptions because stop-and-archive calls
stop_container in a retry loop and decides whether it worked by reading the container's
status afterwards; raising would abort a workflow built to tolerate a stop that did not
take. remove-container keeps its 200 for the same class of reason, called out at the call
site: that route also archives, and a container already gone is a bot whose data still
needs archiving.
Reported by @david-hummingbot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
|
@david-hummingbot the Both halves of it confirmed. A missing container now answers 404 with I took the whole shape rather than the one route. Every container operation in Two decisions worth stating, since both are cases where the obvious fix would have been wrong: Failures stay in-band rather than becoming exceptions.
13 tests, driven by the exception docker-py actually raises (socket URL and The other threeNot touched yet — say which you want first and I'll take them in order:
|
What this is
Two things on one branch: executor performance becomes a first-class read surface, and the improvements backlog is burned down to empty — 18 items closed against a test suite that, at the start, could not actually run.
Part 1 — Executor performance as a read surface
Before this branch, only controllers had a performance history. An executor's numbers lived in memory and were lost on restart: reap a live executor after a crash and its
ExecutorRecordwas terminated asSYSTEM_CLEANUPcarrying creation-time zeros, so/executors/performancebooked real PnL at 0. There was no way to chart an executor at all.Now:
ExecutorRecordupdate (behind a SAVEPOINT, so a failing snapshot INSERT can't lose the completion). A restart's reap now adopts the last snapshot's PnL, fees and filled amount instead of zeros.GET /performance/historyandGET /performance/latest— in one normalized row shape. A consumer writesseriesFor(scope)andlatestFor(scope)once instead of branching on controller-vs-executor; the branch is asubject=query parameter, so it stays a parameter to the client too.is_terminalandclose_type— "its final value" and "its current value" are the same query, with no join back toexecutors./bot-orchestration/controller-performance-historyand-latestare untouched and byte-identical, and the new controller subject goes through the exact same orchestrator methods, so old and new cannot drift. Nothing has to migrate before it wants to.Two new settings, both defaulting to today's behaviour:
PERFORMANCE_EXECUTOR_SNAPSHOT_INTERVAL(60s — finer than the controller dump because executors are short-lived; at a 5-minute grain a three-minute position executor gets one point) andPERFORMANCE_RETENTION_DAYS(0= keep everything forever, so an upgrade never starts deleting an operator's history).The design doc is in
features/done/FEAT-001-executor-performance-snapshots.md, including the deviations from the original design and why.Part 2 — The backlog burndown
Start here: the test suite was lying
pytest test/reported 82 failed, 557 passed. 81 of those were not assertions — they were:asyncio_modewas configured nowhere in the tree (nopytest.ini, noconftest.py, no[tool.pytest.ini_options]), andpytest/pytest-asynciowere not declared dependencies, so a freshconda env createproduced an environment that could not runtest/at all. Five test files were written expecting auto mode; every async test in them was collected and then refused.The consequence is the part that matters: a genuine regression was sitting inside that wall of red, indistinguishable from the noise.
CORR-118— an untypedlp_providersilently resolving to a router pool — had a guard test that was already failing and nobody could see it.So the first two commits fix the instrument before using it:
ARCH-116— declareasyncio_mode = "auto"and the test deps in-tree. 81 failures vanish.CORR-118— fix the one real regression that was hiding behind them.Suite: 82 failed / 557 passed → 798 passed, 0 failed. Net +241 tests, every one of them from the items below.
The 18 items
Each item was implemented in isolation, then verified against the integrated tree before the next one started — build, full suite, and
flake8/isorton the touched files. Any item that broke the tree would have been reverted and marked blocked. None were: 18 closed, 0 blocked, 0 reverted.Correctness
459bf48lp_provideris refused at config load instead of silently becoming a router pool6e2e251POST /backtesting/runreports failure as 500 and timeout as 504, instead of HTTP 200 with an error body8f85852RuntimeErrorno longer permanently kills a market-data subscription — only thesend_jsonis guarded, so a fetch blip is logged and retried5657e38gas_tokenrepaired on CLMM liquidity rows left NULL/UNKNOWNby the old chain map, plus an idempotent backfill scriptb4723cfstop-and-archive's silentreturns now mark the bot run errored, instead of leaving it neither archived nor errored951b4e9limit(1000)1d23303get_performance_reportraises instead of reporting a zeroed report as if it were reale8a8c3fupdate_intervalanswers with a WebSocket error frame instead of crashing the handlerPerformance
bee217c5e75ce1reconcile_active_orders: one batched SELECT and a single conditional flush, not one of each per orderbc02753get_executor_stats: 7 statements → 2, return shape unchangedbafaddcRetry-Afterd080507Architecture and readability
f532cf98e7e841grep get_session_context routers/is now empty6868769build_position_row()1477625ce19104.envtemplate documents all five undocumented settings groups, with a test pinning it againstconfig.pyThree findings worth a reviewer's attention
1. The docker prefix guard was inverted, not just vestigial (
READ-115).create_hummingbot_instancepassesname=instance_nameverbatim, so thehummingbot-check rejected every container the API creates — while accepting exactly the infrastructure containers (hummingbot-api,hummingbot-postgres,hummingbot-broker,hummingbot-tailscale) and then trying to archive abots/instances/directory they never had. It is now an ownership check: a container is removable only if it owns the directory the endpoint archives. That also closed an incidental path-traversal hole in how that path was built.2. The CLMM row shape was reconciled to the union, not the intersection (
ARCH-103). The poller and the open route wrote different column sets for the same table. Dropping the poller's four extra columns would have been the smaller diff, but bin IDs and pending fees have no other reader — so both paths now write the union through one builder: NULL for what a path cannot know,0only where0is the fact. Two narrow value changes ride along:percentageis computed onDecimalon both paths, and a degenerateupper_price == 0yields NULL rather than-1on the discovery path.3. The candle cache was deliberately built to not undo
CORR-060(PERF-112). The obvious implementation of "stop re-downloading candles" is to share the engine — which is exactly the shared-mutable-state bugCORR-060closed. What crosses runs here is candle data only: no engine, no data provider, no controller, each reader unpickling its own copy. The cache is bounded three independent ways (LRU count, TTL, and exact-range keys so a wider or shifted window misses rather than serving short), with a per-key single flight so "download once" holds whenBACKTESTING_MAX_CONCURRENTis raised.test_backtest_concurrency.py,test_backtest_offloading.pyandtest_backtest_storage.pypass unmodified.Earlier on the branch
Correctness and safety: stop accepting API credentials in the WebSocket query string (they end up in access logs and proxy history); run each backtest in its own killable worker process; stop concurrent callers orphaning duplicate candle feeds; persist a completion whose creation row is not there yet; validate controller config names read back from the script YAML; read the EVM transaction id everywhere from one parser.
Performance: Sharpe ratio from aggregates rather than every PnL row; the in-flight order book in one SELECT when syncing; a bounded MQTT log dedup cache that is not rescanned per message.
Architecture: the CLMM and swap routes get a service to persist through; the gateway availability 503 becomes a route dependency rather than body code; the executor push loops are driven by one polling loop with configurable interval bounds;
stop-and-archiveaddresses the bot by one name; the config-map endpoint exposes config field prompts.Known follow-ups (found during the sweep, deliberately not fixed here)
BotRunRepository.update_bot_run_stoppedmatches onlyRUNNING/CREATEDrows, butstop-and-archivehas already set the row toSTOPPEDby the time it runs — so every error-message update in that function, including the onesCORR-106adds here, can silently no-op against a real database. This partly undercutsCORR-106and is the most urgent of the three.setup.shwritesGATEWAY_URL=http://localhost:15888whileconfig.pydefaults tohttps://perSEC-048. The new drift test does not catch it — it only checks defaults on commented lines..github/workflows/contains only the Docker buildx job, which is precisely why 82 failures went unnoticed. Now that the suite is green and runnable from a clean checkout, a CI job is finally meaningful — and is the single highest-value follow-up on this list.Testing
798 passed, 0 failed, verified on the integrated tree after every single item — not once at the end.flake8 --max-line-length=130andisort --check-onlyclean on every touched file; the repo's pre-commit gate passed on every commit with no--no-verify.🤖 Generated with Claude Code
https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne