feat(agents): add Bollinger Band Trader agent - #183
Draft
nikspz wants to merge 3 commits into
Draft
Conversation
A directional volatility-band agent built around a two-stage decision: classify the volatility regime first, read %B second. The same band touch is a fade in a flat range and a buy signal out of a squeeze, and treating the touch itself as the signal is where this indicator normally loses money. Adds under agents/bollinger_band_trader/: - AGENT.md — identity, the four setups (squeeze breakout, band walk, range reversion, failed breakout), veto rules, and risk defaults - HOW_IT_WORKS.md — data sources, indicator definitions, the classification table, and known limitations - validation.md — what was verified, the bugs testing found, and what remains unproven - routines/band_state.py — the primary read: BB(20,2), %B, bandwidth percentile rank, Keltner (TTM) containment and Wilder ADX across 15m/1h/4h, combined into one setup with band-derived entry/stop/target - routines/squeeze_screener.py — ranks a watchlist by bandwidth percentile so the tightest coils surface first - routines/band_trade_sizer.py — converts a band-derived stop into a base-currency amount at fixed fractional risk, then runs seven guardrails; emits a position_executor payload only on PASS - skills/bollinger_playbook/SKILL.md — the classify → size → deploy → manage procedure - strategies/bb_squeeze_breakout/strategy.md — optional loop playbook with the full position_executor schema, defaulting to dry_run Also adds tests/test_bollinger_band_trader.py: 58 tests covering the band math against hand-computed values, ADX behaviour, one fixture per regime, a reachability sweep proving no verdict is dead code, a 2,400-case fuzz asserting no setup ever emits an inverted stop, and every sizing guardrail. Three defects found and fixed while testing, each now pinned by a named regression test: - a firing squeeze reported squeeze_pending, because the squeeze check ran before the expansion check and bandwidth rank is still low on the bar that breaks — the agent stood aside on the exact candle the setup exists to trade - reversion_range was unreachable: the ADX was a raw single-window DX, which saturates near 100 on any directional push, and reaching a band is a directional push. Measured across 600 synthetic ranges, a band tag never once produced a reversion verdict. Replaced with Wilder's ADX - a position size reduced by the cap or the reserve rendered as PASS, hiding that the trade risked less than the configured target Full suite: 286 passed, 5 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nikspz
marked this pull request as draft
July 30, 2026 19:29
Four defects found by running the agent live against hyperliquid_perpetual, plus one repo-level fix that makes small/free models viable at all. agents/bollinger_band_trader/AGENT.md - add `manage_routines` to the tools allowlist. Without it the agent could not call its own routines and silently fell back to fetching raw candles and doing band math in-model: "squeeze_on: undetermined", bandwidth eyeballed as "visually low", no ADX. The allowlist is only enforced for pydantic-ai keys, so this stayed hidden under the original claude-acp key and surfaced the moment a cloud model was used. Same question before/after: 158s -> 45s, "bw_rank visually low" -> exact rank 2/1/21. - default agent_key -> a free tool-capable model. Measured on this agent, a Sonnet-class model runs ~101k input tokens per tick (~$0.43); at a 15m cadence that is ~$41/day, which is indefensible for an agent whose routines already compute the entire decision. AGENT.md / SKILL.md / strategy.md - routine invocations used `manage_trading_agent(action="run_routine", ...)` in four places and a bare `run_routine(...)` in two. Neither is what the runtime advertises to an agent; `condor/agents/prompts.py` tells it to call `manage_routines(action="run", name=..., strategy_id=..., config=...)`. strategy.md - Step 2 told the tick to query `manage_executors(action="list")`. That data is already pre-loaded in [CORE DATA] and [RISK STATE], and the query was where a free model burned its retry budget guessing the argument schema, aborting the whole tick. Reading the pre-loaded data instead cut tool calls per tick from 3-9 down to 1. routines/*.py - `model_config = ConfigDict(extra="forbid")` on all three Configs. Every field has a default, so a model passing `symbol` instead of `trading_pair` had its key silently dropped and the routine analysed BTC-USDT on binance_perpetual while returning a confident, correct-looking answer about the wrong market. Three of four free models did exactly that when probed. Now raises, and the runner surfaces it as a retryable "Invalid config" error. condor/acp/pydantic_ai_client.py - pydantic-ai defaults to retries=1. Small and free models routinely mis-guess a tool's argument schema on the first attempt, and one retry is often not enough to recover; the run is then abandoned with "Exceeded maximum retries (1) for output validation" even though the tick had already done its real work. Raised to 4 via a named constant. Verified live on hyperliquid_perpetual: the agent runs band_state, applies the higher-timeframe veto, and holds correctly on a no_trade verdict at 1 tool call per tick and $0.000000 per tick on a free model. Tests: `pytest tests/` -> 291 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found by watching the live loop run on a free model. AGENT.md — allowlist trading_agent_journal_write / _read The loop runtime instructs every tick: "Write ONE action entry per tick via trading_agent_journal_write". The tools allowlist omitted it, and _prepare_tools strips non-allowlisted tools from the toolset, so an obedient tick called a tool that did not exist, exhausted its retry budget, and reported "Exceeded maximum retries (4) for output validation" *after* having already run band_state and reached the correct verdict. Two of four consecutive ticks failed this way. With the journal tools allowlisted: 0 errors, and tick duration drops from ~105s to 23s. This is the same class of bug as the missing manage_routines entry — an allowlist that omits a tool the runtime requires fails silently at the far end of the tick, after all the real work is done. HOW_IT_WORKS.md — replace the cost section It claimed "a few cents a day", which is wrong by roughly three orders of magnitude. Measured over a real 19-tick session: ~20,600 tokens of base context (system prompt + 25 tool schemas) resent on every model call, 1-10 round-trips per tick, ~101,000 input tokens per tick against 110 tokens of actual signal. That is ~$0.43/tick on a Sonnet-class model, ~$41/day at a 15m cadence — and ~$20/day even on a Haiku-class model, so "cheap Anthropic" is not cheap at this token volume. Replaced with the measured figures and the two conclusions that follow: choose the model deliberately, and note the LLM is not what produces the decision. Tests: `pytest tests/` -> 291 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A directional volatility-band agent built around a two-stage decision: classify the volatility regime first, read %B second.
The same band touch is a fade in a flat range and a buy signal out of a squeeze. Treating the touch itself as the signal is where this indicator normally loses money, so every routine, veto and exit rule here serves that ordering.
What's added
The agent is useful at every layer: consulted with no loop running it answers band questions from the routines; given the strategy it can run the same logic unattended.
The four setups
Three gates can turn any of them into
no_trade: the higher-timeframe veto (never fade a 4h band walk), a reward:risk floor (1.5 continuation / 1.2 reversion), and a volume filter on breakouts.Routines
band_state— BB(20, 2), %B, bandwidth, and the percentile rank of that bandwidth within its own 200-period history across 15m / 1h / 4h. The percentile is what makes this work across assets: raw bandwidth of 1.2% is a tight coil on a major and a dead-flat range on a small-cap. Adds a Keltner-containment check (the classic TTM squeeze) as an independent second squeeze test, plus Wilder ADX and a volume ratio, then emits one setup with concrete prices.squeeze_screener— ranks a watchlist by bandwidth percentile ascending so the tightest coils surface first, and distinguishes a coil that is resting from one that is firing. A squeeze has no direction, so the output is explicitly a watchlist, not a set of trades.band_trade_sizer— the only path to a position size. Fixed fractional risk (notional = risk_quote / stop_pct) so a wide-stop setup automatically gets a smaller position and every trade risks the same amount. Seven guardrails follow: portfolio known, stop on the protective side, stop distance 0.05–20%, risk ≤ 1%, leverage ≤ 5, notional above the exchange minimum, margin inside a 20% reserve. Any FAIL blocks the trade and no payload is emitted.All indicator math is plain Python inside the routines — no pandas, no TA library — so the definitions are auditable in place.
Tests
uv run pytest tests/test_bollinger_band_trader.py— 58 tests, ~1.7s, no network and no Hummingbot server. Full suite: 286 passed, 5 skipped.Beyond the unit checks, two are worth calling out:
Design decisions the tests forced
Three choices below look arbitrary in the diff. Each is there because the obvious
version was tried first and measurably failed. Each is pinned by a named regression test.
Expansion is checked before squeeze (
_verdict). Bandwidth rank is computed fromhistory, so on the exact candle a coil breaks the rank is still low. Checking squeeze
first made the routine report
squeeze_pendingon the one bar the whole setup exists totrade. →
test_firing_coil_reads_as_expansion_not_squeezeADX is Wilder-smoothed, not the raw single-window DX that
market_analyzeruses.Raw DX saturates near 100 on any sustained directional push — and reaching a Bollinger
band is a directional push. Measured: across 600 synthetic ranges a band tag never once
produced a
reversion_rangeverdict, so that setup was unreachable in practice. Notethis means the two agents report different ADX for the same market; this one is the
textbook figure. →
test_adx_does_not_pin_at_100_in_a_rangeA capped position size is a WARN row, not PASS. When the position cap or the reserve
reduces the size, the trade is still valid but risks less than the configured target. As
a PASS the operator had no signal that had happened. →
test_capped_size_is_reported_as_a_warn_rowA fourth, smaller one: the band-walk threshold was
%B >= 0.95, which a smooth trendnever reaches — it rides at 0.90–0.93. Relaxed to the top decile and gated on ADX > 25 so
a range cannot be mistaken for a walk.
Second commit: fixes from running it live
The agent was then run against live
hyperliquid_perpetualdata, which surfaced four moredefects that only appear at runtime:
manage_routineswas missing from the tools allowlist, so the agent could not callits own routines and silently did band math in-model instead. The allowlist is only
enforced on pydantic-ai keys, so this was invisible under an ACP key.
AGENT.md,SKILL.mdand
strategy.md.strategy.mdtold the tick to query executors that are already pre-loaded in[CORE DATA], costing a round-trip and a retry-budget failure.extra="forbid"on the routine Configs — every field had a default, so a modelpassing
symbolinstead oftrading_pairsilently analysed BTC-USDT on Binance whilereporting confidently about the requested pair.
Plus one repo-level fix in
condor/acp/pydantic_ai_client.py: pydantic-ai's defaultretries=1is too tight for small models, which routinely mis-guess a tool schema onceand then abort the whole run.
Verified live: 1 tool call per tick, correct
no_tradeverdict with the higher-timeframeveto applied, on a free model at $0.000000 per tick.
What is not validated
band_stateagainst a real server before launching a loop.squeeze_rank, the R:R floors, the ADX split) are starting points to tune per market.mcp_servers/hummingbot_api/guides/position_executor.md. The strategy defaults toexecution_mode: dry_run.Full detail in
agents/bollinger_band_trader/validation.md.