Skip to content

feat(agents): add Bollinger Band Trader agent - #183

Draft
nikspz wants to merge 3 commits into
mainfrom
feat/bollinger-band-trader-agent
Draft

feat(agents): add Bollinger Band Trader agent#183
nikspz wants to merge 3 commits into
mainfrom
feat/bollinger-band-trader-agent

Conversation

@nikspz

@nikspz nikspz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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

agents/bollinger_band_trader/
  AGENT.md                                   # identity, the four setups, veto rules, risk defaults
  HOW_IT_WORKS.md                            # data sources, indicator math, classification table, limits
  validation.md                              # what was verified, what wasn't
  routines/band_state.py                     # primary read: classify + derive entry/stop/target
  routines/squeeze_screener.py               # rank a watchlist by squeeze tightness
  routines/band_trade_sizer.py               # risk sizing + pre-trade guardrails
  skills/bollinger_playbook/SKILL.md         # classify → size → deploy → manage
  strategies/bb_squeeze_breakout/strategy.md # optional loop playbook (defaults to dry_run)
tests/test_bollinger_band_trader.py          # 58 tests

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

Setup Entry Stop Target
Squeeze breakout close beyond the band middle band 2R, then trail the mean
Band walk pullback to the middle band 1 ATR past the mean opposite band
Range reversion at the band 0.5 ATR beyond it middle band
Failed breakout on the close back inside beyond the failure extreme middle band, then opposite

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:

  • A reachability sweep asserts every classification verdict fires on realistic data. A branch that never fires is dead code, and this is what catches it.
  • A 2,400-case fuzz runs every regime fixture against every trend verdict and asserts no emitted setup ever has an inverted stop or target. An inverted stop is an instant loss, not a bad trade.

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 from
history, so on the exact candle a coil breaks the rank is still low. Checking squeeze
first made the routine report squeeze_pending on the one bar the whole setup exists to
trade. → test_firing_coil_reads_as_expansion_not_squeeze

ADX is Wilder-smoothed, not the raw single-window DX that market_analyzer uses.
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_range verdict, so that setup was unreachable in practice. Note
this 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_range

A 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_row

A fourth, smaller one: the band-walk threshold was %B >= 0.95, which a smooth trend
never 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_perpetual data, which surfaced four more
defects that only appear at runtime:

  • manage_routines was missing from the tools allowlist, so the agent could not call
    its 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.
  • Routine invocations named the wrong tool in six places across AGENT.md, SKILL.md
    and strategy.md.
  • strategy.md told 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 model
    passing symbol instead of trading_pair silently analysed BTC-USDT on Binance while
    reporting confidently about the requested pair.

Plus one repo-level fix in condor/acp/pydantic_ai_client.py: pydantic-ai's default
retries=1 is too tight for small models, which routinely mis-guess a tool schema once
and then abort the whole run.

Verified live: 1 tool call per tick, correct no_trade verdict with the higher-timeframe
veto applied, on a free model at $0.000000 per tick.

What is not validated

  • No live exchange run. Every test uses synthetic candles through a fake client. Connector-specific response shapes are handled defensively but unproven — run band_state against a real server before launching a loop.
  • No profitability claim. Nothing here is a backtest. The tests prove the agent computes what it says and refuses what it says — not that the setups make money. The thresholds (squeeze_rank, the R:R floors, the ADX split) are starting points to tune per market.
  • No executor was created. Only the payload shape is checked, against mcp_servers/hummingbot_api/guides/position_executor.md. The strategy defaults to execution_mode: dry_run.

Full detail in agents/bollinger_band_trader/validation.md.

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
nikspz marked this pull request as draft July 30, 2026 19:29
nikspz and others added 2 commits July 31, 2026 18:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant