# Almanak Strategy Builder

> Build, test, and deploy DeFi trading strategies using the Almanak SDK. ALWAYS use this skill when the user mentions almanak, DeFi strategy, trading strategy, yield farming, liquidity provision, token swap, borrowing, lending, perpetuals, staking, vault deposit, bridging tokens, backtesting, paper trading, or on-chain execution. Use for writing strategy.py files, composing intents (Swap, LP, Borrow, Supply, Perp, Bridge, Stake, Vault, Prediction), working with config.json strategy parameters, running almanak strat or almanak gateway CLI commands, or debugging strategy execution on Anvil forks. Do NOT use for general smart contract development, Solidity code, or non-strategy SDK internals.

- Skill: `almanak-co/almanak-strategy-builder` (Agent Skill)
- Install (CLI): `npx skillmds@latest add almanak-co/almanak-strategy-builder`
- Raw SKILL.md: https://api.skillmd.com/api/skills/almanak-co/almanak-strategy-builder/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: almanak-co (https://skillmd.com/u/almanak-co)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/almanak-co/almanak-strategy-builder

---


# Almanak Strategy Builder

You are helping a quant build DeFi strategies using the Almanak SDK.
Strategies are Python classes that return Intent objects. The framework handles
compilation to transactions, execution, and state management.

<!-- almanak-sdk-start: security-model -->

## Security Model

This file is **documentation for AI coding assistants** - it contains no executable code or live credentials.

The Almanak SDK uses a **gateway-isolated architecture**:
- Strategy containers have **no direct access** to secrets, RPC endpoints, or external APIs.
- All external access is mediated through a gRPC gateway sidecar.
- In production, secrets are injected by the platform secret manager into the gateway - never into the strategy container.
- The `.env` file referenced below is for **local development only** and is never deployed to production environments.
- Private keys in `.env` are used solely for local Anvil fork testing and have no access to mainnet funds in production.

See the [Gateway](#gateway) section for details.

<!-- almanak-sdk-end: security-model -->

**IMPORTANT — Zodiac permissions auto-generation**: Whenever you create a new
strategy or modify `strategy.py` in a way that changes protocols, intent types,
tokens, or chains, you MUST regenerate the Zodiac Roles permission manifest
by running `almanak strat permissions -o permissions.json`
from the strategy directory. The file MUST be named exactly `permissions.json`
— the platform deploy pipeline hardcodes this filename. This ensures the Safe
wallet permissions stay in sync with the strategy's actual contract interactions.
Always do this as a final step after writing or editing strategy code.

<!-- almanak-sdk-start: quick-start -->

## Quick Start

```bash
# Install the CLI globally
pipx install almanak

# Scaffold a new strategy (creates a self-contained Python project)
almanak strat new --template ta_swap --name my_rsi --chain arbitrum

# Run on local Anvil fork (auto-starts gateway + Anvil)
cd my_rsi
almanak strat run --network anvil --once

# Run a single iteration on mainnet
almanak strat run --once

# Browse and copy a working demo strategy
almanak strat demo
```

Each scaffolded strategy is a **self-contained Python project** with its own
`pyproject.toml`, `.venv/`, and `uv.lock`. The same files drive both local
development and the platform's cloud Docker build.

**Strategy project structure:**

```
my_strategy/
  strategy.py        # IntentStrategy subclass with decide() method
  config.json        # Runtime parameters (tokens, thresholds, funding)
  pyproject.toml     # Dependencies + [tool.almanak] metadata
  uv.lock            # Locked dependencies (created by uv sync)
  .venv/             # Per-strategy virtual environment
  .env               # Local dev credentials (not deployed; see Security Model)
  .gitignore         # Git ignore rules
  .python-version    # Python version pin (3.12)
  __init__.py        # Package exports
  tests/             # Test scaffold
  AGENTS.md          # AI agent guide
```

**pyproject.toml example:**

```toml
[project]
name = "my-strategy"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "almanak>=2.15.0",
]

[tool.almanak.run]
interval = 60
```

The `[tool.almanak.run]` section is required — it sets the execution interval (in seconds)
for the strategy loop in production. Always include it when writing pyproject.toml manually.

**Adding dependencies:**

```bash
uv add pandas-ta          # Updates pyproject.toml + uv.lock + .venv/
uv run pytest tests/ -v   # Run tests in the strategy's venv
```

For Anvil testing, add `anvil_funding` to `config.json` so your wallet is auto-funded on fork start
(see [Configuration](#configuration) below).

```python
# strategy.py
from decimal import Decimal
from almanak import MarketSnapshot
from almanak.framework.strategies import IntentStrategy, almanak_strategy
from almanak.framework.intents import Intent

@almanak_strategy(
    name="my_strategy",
    version="1.0.0",
    supported_chains=["arbitrum"],
    supported_protocols=["uniswap_v3"],
    intent_types=["SWAP", "HOLD"],
    default_chain="arbitrum",
)
class MyStrategy(IntentStrategy):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.trade_size = Decimal(str(self.config.get("trade_size_usd", "100")))

    def decide(self, market: MarketSnapshot) -> Intent | None:
        rsi = market.rsi("WETH", period=14)
        if rsi.value < 30:
            return Intent.swap(
                from_token="USDC", to_token="WETH",
                amount_usd=self.trade_size, max_slippage=Decimal("0.005"),
            )
        return Intent.hold(reason=f"RSI={rsi.value:.1f}, waiting")
```

> **Note:** `amount_usd=` requires a live price oracle from the gateway. If swaps revert with
> "Too little received", switch to `amount=` (token units) which bypasses USD-to-token conversion.
> Always verify pricing on first live run with `--dry-run --once`.

<!-- almanak-sdk-end: quick-start -->

<!-- almanak-sdk-start: core-concepts -->

## Core Concepts

### IntentStrategy

All strategies inherit from `IntentStrategy` and implement one method:

```python
def decide(self, market: MarketSnapshot) -> Intent | None
```

The framework calls `decide()` on each iteration with a fresh `MarketSnapshot`.
Return an `Intent` object (swap, LP, borrow, etc.) or `Intent.hold()`.

### Lifecycle

1. `__init__`: Extract config parameters, set up state
2. `decide(market)`: Called each iteration - return an Intent
3. `on_intent_executed(intent, success, result)`: Optional callback after execution
4. `get_status()`: Optional - return dict for monitoring dashboards
5. `supports_teardown()` / `generate_teardown_intents()`: Optional safe shutdown

### @almanak_strategy Decorator

Attaches metadata used by the framework and CLI:

```python
@almanak_strategy(
    name="my_strategy",              # Unique identifier
    description="What it does",      # Human-readable description
    version="1.0.0",                 # Strategy version
    author="Your Name",              # Optional
    tags=["trading", "rsi"],         # Optional tags for discovery
    supported_chains=["arbitrum"],   # Which chains this runs on
    supported_protocols=["uniswap_v3"],  # Which protocols it uses
    intent_types=["SWAP", "HOLD"],   # Intent types it may return
    default_chain="arbitrum",        # Default chain for execution
    quote_asset="USD",               # Asset performance is measured in (USD default, or a token)
)
```

**IMPORTANT — Intent Type Teardown Complements**: `intent_types` must include
both the "open" and "close" side of every operation. These are used to generate
Zodiac Roles permissions for Safe wallet deployments. If you declare the open
side without its complement, the strategy will deploy but **teardown will fail
on-chain** because the wallet lacks permission for the close operation.

| If you declare... | You MUST also declare... |
|--------------------|--------------------------|
| `SUPPLY`           | `WITHDRAW`               |
| `BORROW`           | `REPAY`                  |
| `LP_OPEN`          | `LP_CLOSE`               |
| `VAULT_DEPOSIT`    | `VAULT_REDEEM`           |
| `PERP_OPEN`        | `PERP_CLOSE`             |

The decorator emits a `UserWarning` at import time if complements are missing.
The permission generator also auto-expands missing complements as a safety net,
but always declare them explicitly.

### Quote asset (performance denomination)

`quote_asset` declares the asset your strategy's performance is measured in. It defaults
to **USD** and sets the numeraire for performance reporting: backtests and paper runs
compute their canonical performance metrics in it (`performance_denomination` in the
result summary names the unit; `*_usd` counterparts are kept alongside), and the hosted
platform reports performance in it. It does
not change execution behaviour — only how results are measured — so a wrong value reports
performance in the wrong unit: a BTC-growth strategy declared `"USD"` shows USD PnL and no
BTC-denominated metrics. Choose by asking what quantity the strategy is trying to grow —
if the goal is stated ("increase BTC"), the denomination must match it.

- **USD (default):** `quote_asset="USD"`. Declare it explicitly rather than omitting it —
  the scaffold and packaged demos do, and an explicit value makes the choice reviewable.
- **Token:** `quote_asset={"type": "token", "chain_id": <int>, "address": "0x..."}` (or
  `QuoteAsset.token(chain_id, address)` from `almanak.core.models.quote_asset`),
  identifying the token by its canonical `(chain_id, address)`. Use a **numeric `chain_id`
  only**, never a chain name. Represent native gas tokens by their wrapped ERC-20
  (ETH->WETH, MNT->WMNT, 0G->W0G).

Set a **token** quote asset only when the strategy's goal is to grow a quantity of that
token — pure accumulators, ETH-denominated LST leverage loops (collateral *and* borrow are
ETH-family), native-asset staking, and same-asset-family LP pools built to grow that asset
(e.g. a WBTC/tBTC pool as a BTC accumulator quotes in WBTC). Mixed-family LP (e.g.
WETH/USDC), USD-yield lending, stablecoin, delta-neutral, and USD-collateral perp
strategies stay on the USD default. `quote_asset` is distinct from `quote_token` (a
trading-pair leg) and `starting_asset` (an LP round-trip asset).

You can also set it per-deployment in `config.json` (`"quote_asset": "USD"` or the token
object), which overrides the decorator default on live runs at boot (backtests read the
decorator value). It is frozen at boot — not hot-reloadable. When denominating a strategy
for backtesting, set it on the decorator.

### Config Access

In `__init__`, read parameters from `self.config` (dict loaded from config.json):

```python
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.trade_size = Decimal(str(self.config.get("trade_size_usd", "100")))
    self.rsi_period = int(self.config.get("rsi_period", 14))
    self.base_token = self.config.get("base_token", "WETH")
```

Also available: `self.chain` (str), `self.wallet_address` (str), `self.chains` (list[str]),
`self.get_wallet_for_chain(chain)` (str).

<!-- almanak-sdk-end: core-concepts -->

<!-- almanak-sdk-start: intent-vocabulary -->

## Intent Reference

All intents are created via `Intent` factory methods. Import:

```python
from almanak.framework.intents import Intent
```

### Reserved fields on every intent

`BaseIntent.registry_handle` (added by VIB-4192 / T06b; factory ergonomics
lifted by VIB-4285) is an optional opaque field that disambiguates multiple positions on
the same `(primitive, semantic_group)`.

- **Single-position strategies** (the common case): leave it unset. The runner threads
  the auto-assigned handle through ADJUST / CLOSE intents from the prior open's result.
- **Multi-position strategies** (e.g. two LP legs on the same pool): pass an explicit
  per-leg handle on every OPEN so the auto-mode collision guard (`ix_registry_auto_mode`
  partial unique index) does not reject the second open. Use **stable per-position
  handles** (`leg_narrow`, `leg_wide`) — NOT action-scoped suffixes
  (`leg_narrow:open` / `leg_narrow:close`), so the same handle survives the full
  open → close → rebalance lifecycle. Example:
  `Intent.lp_open(..., registry_handle="hedge_leg_long")`.

Synthesising a handle that does not match the prior open will fail at
`save_ledger_and_registry` with `RegistryAutoCollisionError`. See
`../../../blueprints/28-position-registry.md` §3.5 and §6 anti-pattern #13 for the
contract.

### Dispatching multiple opens on the same pool

The reserved-field rule above gets `registry_handle` right; the rule below is about
the **dispatch cadence**. They are complementary — both must be right for a
multi-position strategy to work.

**Emit one opening intent per `decide()` iteration**, not as a list. Drive iterations
with a `_phase` field that advances only when `on_intent_executed` observes a real
`position_id` on the receipt. The list-return shape (`return [open_a, open_b]`) and
`Intent.sequence([open_a, open_b])` both commit two legs to a single market snapshot,
give leg 2 no opportunity to re-size against leg 1's actual on-chain output, and
provide no clean partial-success state. Reference implementation:
`strategies/accounting/lp_dual/strategy.py` (two LPs, one pool, phase machine,
self-sized amounts, position-id-keyed close).

Self-size each leg from live `market.balance(...)` at the moment the open is built —
leg #1 takes `commit_pct` of the available balance, leg #2 takes `0.99` of what
remains (the 1% safety margin absorbs gas / dust / slippage drift between balance
read and tx submission). Hardcoded per-leg amounts in `config.json` work in steady
state but desync against any real-world mint slippage; the live-balance pattern is
what `lp_dual` / `lp_triple` use because mint slippage is observable on every
real-Anvil run.

Skeleton:

```python
PHASE_INIT = "init"
PHASE_LP1_OPEN = "lp1_open"
PHASE_BOTH_OPEN = "both_open"

def decide(self, market):
    if self._phase == PHASE_INIT:
        return self._build_lp_open(market, position_index=1)
    if self._phase == PHASE_LP1_OPEN:
        return self._build_lp_open(market, position_index=2)
    if self._phase == PHASE_BOTH_OPEN:
        return Intent.hold(reason="Both LPs open — awaiting teardown")
    return Intent.hold(reason=f"Unknown phase {self._phase!r}")

def _build_lp_open(self, market, *, position_index):
    token0_balance = Decimal(str(market.balance(self.token0_symbol).balance))
    token1_balance = Decimal(str(market.balance(self.token1_symbol).balance))
    if position_index == 1:
        commit_pct = self.lp_capital_split_pct       # e.g. 0.50
        handle = "leg_narrow"
    else:
        commit_pct = Decimal("0.99")                 # leg 2 takes what's left
        handle = "leg_wide"
    return Intent.lp_open(
        pool=self.pool,
        amount0=token0_balance * commit_pct,
        amount1=token1_balance * commit_pct,
        range_lower=...,
        range_upper=...,
        registry_handle=handle,
    )

def on_intent_executed(self, intent, success, result):
    if not success or intent.intent_type.value != "LP_OPEN":
        return  # phase stays put → next iteration retries
    position_id = getattr(result, "position_id", None)
    if not position_id:
        return  # mint without id → don't advance, retry next tick (prevents stranding)
    if self._phase == PHASE_INIT:
        self._position_id_1 = str(position_id)
        self._phase = PHASE_LP1_OPEN
    elif self._phase == PHASE_LP1_OPEN:
        self._position_id_2 = str(position_id)
        self._phase = PHASE_BOTH_OPEN
```

State-transition shape:

```text
INIT ──LP_OPEN(narrow)──▶ LP1_OPEN ──LP_OPEN(wide)──▶ BOTH_OPEN ──teardown──▶ DONE
```

For richer patterns (out-of-order middle close on three positions), see
`strategies/accounting/lp_triple/strategy.py`. The full design contract lives in
`../../../blueprints/04-strategy-layer.md` §Multi-position dispatch.

### Not-yet-implemented IntentType values (fail-fast at compile time)

The following IntentType strings are **placeholders** in the canonical taxonomy. They
exist to reserve the name and primitive classification but have **no compiler / executor
behind them**. Emitting one of these from strategy code raises `PlaceholderIntentError`
at intent-compile time (before any on-chain action) and is also refused at the
PolicyEngine boundary for LLM-mediated surfaces:

| IntentType | Future primitive |
|---|---|
| `LIQUIDATE` | LIQUIDATION |
| `OPEN_CDP` | CDP |
| `MINT_STABLE` | CDP |
| `REPAY_STABLE` | CDP |
| `CLOSE_CDP` | CDP |

Do not emit these. When the corresponding primitive ships, the placeholder rows will be
swapped for real handlers atomically (taxonomy + compiler + handler in one PR) — your
strategy code does not change.

### Trading

**Intent.swap** - Exchange tokens on a DEX

```python
Intent.swap(
    from_token="USDC",           # Token to sell
    to_token="WETH",             # Token to buy
    amount_usd=Decimal("1000"),  # Amount in USD (use amount_usd OR amount)
    amount=Decimal("500"),       # Amount in token units (alternative to amount_usd)
    max_slippage=Decimal("0.005"),  # Max slippage (0.5%)
    max_price_impact=Decimal("0.10"),  # Optional: max quoter-vs-oracle deviation (default: 10%; override for thin venues)
    protocol="uniswap_v3",      # Optional: specific DEX
    chain="arbitrum",            # Optional: override chain
    destination_chain="base",    # Optional: cross-chain swap
    swap_params=None,             # Optional connector-owned exact-route constraints
)
```

Use `amount="all"` to swap the entire balance.

**`amount=` vs `amount_usd=`**: Use `amount_usd=` to specify trade size in USD (requires a live
price oracle from the gateway). Use `amount=` to specify exact token units (more reliable for live
trading since it bypasses USD-to-token conversion). When in doubt, prefer `amount=` for mainnet.

#### Pinning one V3 execution pool

For `uniswap_v3`, `sushiswap_v3`, `pancakeswap_v3`, and `agni_finance`, pin a same-chain
swap to one immutable pool with `swap_params={"pool": <address>}`. The compiler reads
`token0()`, `token1()`, and `fee()` from that pool and verifies `factory.getPool(...)` before
building the transaction. A wrong pair, foreign factory, unsupported protocol, or unreadable
pool fails compilation; it never falls back to another pool or route.

```python
APPROVED_POOL = "0xc655e1a100a084d9ac91c269b0a7cb0e62263fcf"

Intent.swap(
    from_token=self.quote_token_address,
    to_token=self.base_token_address,
    amount=Decimal("100"),
    max_slippage=Decimal("0.0075"),
    protocol="pancakeswap_v3",
    chain="bsc",
    swap_params={"pool": APPROVED_POOL},
)
```

Use `swap_params={"fee_tier": 500}` only when any factory pool at that fee tier is acceptable;
use `pool` when the address itself is an invariant. Pool pinning is not supported for cross-chain
aggregator swaps. Use the same `swap_params` in normal execution and teardown.

### Liquidity Provision

**Intent.lp_open** - Open a concentrated LP position

```python
Intent.lp_open(
    pool="WETH/USDC",               # Pool identifier
    amount0=Decimal("1.0"),          # Amount of token0
    amount1=Decimal("2000"),         # Amount of token1
    range_lower=Decimal("1800"),     # Lower price bound
    range_upper=Decimal("2200"),     # Upper price bound
    range_spec=None,                 # Typed range: PriceBand | TickBand (alternative to range_lower/range_upper)
    protocol="uniswap_v3",          # Default: uniswap_v3
    chain=None,                      # Optional override
    coin_amounts=None,               # Multi-coin pools (e.g. Curve 3pool): per-coin amounts by pool index
    max_slippage=None,               # Optional slippage bound on the deposit floor
)
```

> **Typed ranges (VIB-5555):** `range_spec` accepts `PriceBand(lower=..., upper=...)`
> (human prices, token1-per-token0 — the portable default, converted to ticks by each
> connector) or `TickBand(lower=..., upper=...)` (raw protocol ticks, escape hatch).
> Import both from `almanak.framework.intents`. The legacy `range_lower`/`range_upper`
> pair is still accepted and equivalent to a `PriceBand`; pass one form, not both.

**Intent.lp_close** - Close an LP position

```python
Intent.lp_close(
    position_id="12345",     # NFT token ID returned by lp_open; ALSO the registry handle
    pool="WETH/USDC",        # Optional pool identifier
    collect_fees=True,       # Collect accumulated fees
    protocol="uniswap_v3",
    amount=None,             # "all" = chain off prior LP_OPEN's minted LP (fungible-LP allowlist, e.g. Pendle)
    max_slippage=None,       # Withdrawal floor for Curve/Aerodrome (each defaults to 50 bps)
    coin_index=None,         # Single-sided exit: withdraw all as one pool coin (Curve only, VIB-5437)
    imbalanced_amounts=None, # Exact per-coin exit amounts, fail-closed max-burn (Curve StableSwap only, VIB-5438)
)
```

> **Curve exit selectors:** `coin_index` routes via `remove_liquidity_one_coin`;
> `imbalanced_amounts` routes via `remove_liquidity_imbalance`. They are mutually
> exclusive; leave both `None` for the proportional all-coin close. Only connectors
> declaring the `lp_close_exit_selectors` capability (currently Curve) compile them.

> **LP close slippage:** Curve and classic Aerodrome consume `max_slippage` and
> default to 50 bps when it is omitted. Aerodrome derives its minimum outputs from
> the router's `quoteRemoveLiquidity` result and refuses compilation if it cannot
> obtain a protective quote. Uniswap V3-family closes still ignore this field and
> submit zero minimums, so setting it does not protect those exits.

> `position_id` from `lp_open`'s result is the registry handle (VIB-4192 / T06b).
> **Persist it in strategy state** (`self.state["lp_position_id"] = result.position_id`)
> and pass it back to `lp_close` at teardown. The framework uses it to look up the open
> row in `position_registry`; a mismatch (handle present but no live row) raises
> `RegistryAutoCollisionError` before any on-chain call.

**Intent.collect_fees** - Harvest LP fees without closing

```python
Intent.collect_fees(
    pool="WETH/USDC",
    protocol="traderjoe_v2",
)
```

### Lending / Borrowing

**Intent.supply** - Deposit collateral into a lending protocol

```python
Intent.supply(
    protocol="aave_v3",
    token="WETH",
    amount=Decimal("10"),
    use_as_collateral=True,   # Enable as collateral (default: True)
    market_id=None,           # Required for Morpho Blue
)
```

**Intent.borrow** - Borrow tokens against collateral

```python
Intent.borrow(
    protocol="aave_v3",
    collateral_token="WETH",
    collateral_amount=Decimal("10"),
    borrow_token="USDC",
    borrow_amount=Decimal("5000"),
    interest_rate_mode="variable",  # Aave: "variable" only (stable deprecated)
    market_id=None,                 # Required for Morpho Blue
)
```

**Intent.repay** - Repay borrowed tokens

```python
Intent.repay(
    protocol="aave_v3",
    token="USDC",
    amount=Decimal("5000"),
    repay_full=False,        # Set True to repay entire debt
    market_id=None,
)
```

**Intent.deleverage** - Emergency repay triggered by risk management (e.g. HF below threshold)

```python
Intent.deleverage(
    protocol="aave_v3",
    token="USDC",
    amount=Decimal("5000"),
    trigger_reason="health_factor_below_threshold",  # Human-readable reason for the deleverage
    observed_hf=Decimal("1.05"),   # Health factor at trigger time (persisted as health_factor_before)
    target_hf=Decimal("1.5"),      # Target HF after deleverage
    repay_full=False,              # Set True to repay entire debt
    market_id=None,
)
```

Compiles to the same on-chain execution as `Intent.repay`. The `trigger_reason`, `observed_hf`,
and `target_hf` are stored in the accounting layer so dashboards can surface why the deleverage
was forced. The `observed_hf` is persisted as `health_factor_before` in the accounting event.
DELEVERAGE is a mandatory live event type (fail-closed) — the runner will log a WARNING when it
detects a deleverage.

**Intent.withdraw** - Withdraw from lending protocol

```python
Intent.withdraw(
    protocol="aave_v3",
    token="WETH",
    amount=Decimal("10"),
    withdraw_all=False,      # Set True to withdraw everything
    market_id=None,
    is_collateral=True,      # Morpho Blue only: True = collateral, False = loan token
)
```

### Perpetuals

**Intent.perp_open** - Open a perpetual futures position

```python
Intent.perp_open(
    market="ETH/USD",
    collateral_token="USDC",
    collateral_amount=Decimal("1000"),
    size_usd=Decimal("5000"),
    is_long=True,
    leverage=Decimal("5"),
    max_slippage=Decimal("0.01"),
    protocol="gmx_v2",
)
```

**Intent.perp_close** - Close a perpetual futures position

```python
Intent.perp_close(
    market="ETH/USD",
    collateral_token="USDC",
    is_long=True,
    size_usd=None,               # None = close full position
    max_slippage=Decimal("0.01"),
    protocol="gmx_v2",
    position_id=None,            # Required for venues keyed on bytes32 (e.g. pancakeswap_perps)
)
```

**Intent.perp_withdraw** - Withdraw free margin off a perp venue's off-chain account back to L1 (a cash movement, not a trade — no position, no PnL). On Hyperliquid this compiles to a CoreWriter perp→spot `usdClassTransfer` followed by a spot→L1 `spotSend` HyperCore→HyperEVM bridge of USDC (VIB-5617).

```python
Intent.perp_withdraw(
    amount=Decimal("6.99"),      # human token amount, or "all" ONLY as a chained amount (a prior step's output)
    asset="USDC",                # the only HyperCore bridge-linked token today
    protocol="hyperliquid",
    chain="hyperevm",
    destination=None,            # defaults to the deployment wallet; the bridge always credits the sender
)
```

**Intent.perp_cancel_order** - Cancel a pending (unfilled) perp order and recover its committed collateral and unspent execution fee (VIB-5568). Not a position open/close — a refund of committed-but-unspent collateral, e.g. to recover a stranded pending order discovered during teardown.

```python
Intent.perp_cancel_order(
    order_key="0x...",           # bytes32 order key (0x-prefixed, 66 chars) from the open receipt or residual discovery
    protocol="gmx_v2",           # Default: gmx_v2
    chain=None,                  # Optional override
)
```

### Bridging

**Intent.bridge** - Cross-chain token transfer

```python
Intent.bridge(
    token="USDC",
    amount=Decimal("1000"),
    from_chain="arbitrum",
    to_chain="base",
    max_slippage=Decimal("0.005"),
    preferred_bridge=None,       # Optional: specific bridge protocol
)
```

### Staking

**Intent.stake** - Liquid staking deposit

```python
Intent.stake(
    protocol="lido",
    token_in="ETH",
    amount=Decimal("10"),
    receive_wrapped=True,    # Receive wrapped token (e.g., wstETH)
)
```

**Intent.unstake** - Withdraw from liquid staking

```python
Intent.unstake(
    protocol="lido",
    token_in="wstETH",
    amount=Decimal("10"),
    protocol_params=None,    # Optional: e.g. {"phase": "cooldown"} for Ethena
)
```

### Flash Loans

**Intent.flash_loan** - Borrow and repay in a single transaction

```python
Intent.flash_loan(
    provider="aave",         # "aave", "balancer", "morpho", or "auto"
    token="USDC",
    amount=Decimal("100000"),
    callback_intents=[...],  # Intents to execute with the borrowed funds
)
```

### Vaults (ERC-4626)

**Intent.vault_deposit** - Deposit into an ERC-4626 vault

```python
Intent.vault_deposit(
    protocol="metamorpho",           # Vault protocol
    vault_address="0x...",           # Vault contract address
    amount=Decimal("1000"),          # Amount of underlying to deposit (or "all")
    deposit_token="USDC",            # Underlying token symbol (for backtesting)
    chain="ethereum",                # Optional: override chain
)
```

**Intent.vault_redeem** - Redeem shares from an ERC-4626 vault

```python
Intent.vault_redeem(
    protocol="metamorpho",           # Vault protocol
    vault_address="0x...",           # Vault contract address
    shares=Decimal("1000"),          # Shares to redeem (or "all")
    deposit_token="USDC",            # Underlying token symbol (for backtesting)
    chain="ethereum",                # Optional: override chain
)
```

### Prediction Markets

```python
Intent.prediction_buy(
    market_id="will-bitcoin-exceed-100000",  # Polymarket market ID or slug
    outcome="YES",                            # "YES" or "NO"
    amount_usd=Decimal("100"),                # USDC to spend (or use shares=)
    protocol="polymarket",
)
Intent.prediction_sell(
    market_id="will-bitcoin-exceed-100000",
    outcome="YES",
    shares=Decimal("50"),                     # Shares to sell (or "all")
    protocol="polymarket",
)
Intent.prediction_redeem(
    market_id="will-bitcoin-exceed-100000",   # Redeem after market resolves
    protocol="polymarket",
)
```

### Cross-Chain

**Intent.ensure_balance** - Meta-intent that resolves to a `BridgeIntent` (if balance is insufficient) or `HoldIntent` (if already met). Call `.resolve(market)` before returning from `decide()`.

```python
intent = Intent.ensure_balance(
    token="USDC",
    min_amount=Decimal("1000"),
    target_chain="arbitrum",
    max_slippage=Decimal("0.005"),
    preferred_bridge=None,
)
# Must resolve before returning - returns BridgeIntent or HoldIntent
resolved = intent.resolve(market)
return resolved
```

### Token Utilities

**Intent.wrap** (WrapNative) - Wrap native tokens to ERC-20 (ETH -> WETH, MATIC -> WMATIC, etc.)

```python
Intent.wrap(
    token="WETH",              # Wrapped token symbol to receive
    amount=Decimal("0.5"),     # Amount of native token to wrap (or "all")
    chain="arbitrum",          # Target chain
)
```

**Intent.unwrap** (UnwrapNative) - Unwrap wrapped native tokens (WETH -> ETH, WMATIC -> MATIC, etc.)

```python
Intent.unwrap(
    token="WETH",              # Wrapped token symbol
    amount=Decimal("0.5"),     # Amount to unwrap (or "all")
    chain="arbitrum",          # Target chain
)
```

### Control Flow

**Intent.hold** - Do nothing this iteration

```python
Intent.hold(reason="RSI in neutral zone")
```

**Intent.sequence** - Execute multiple intents in order

```python
Intent.sequence(
    intents=[
        Intent.swap(from_token="USDC", to_token="WETH", amount_usd=Decimal("1000")),
        Intent.supply(protocol="aave_v3", token="WETH", amount=Decimal("0.5")),
    ],
    description="Buy WETH then supply to Aave",
)
```

### Chained Amounts

Use `"all"` to reference the full output of a prior intent:

```python
Intent.sequence(intents=[
    Intent.swap(from_token="USDC", to_token="WETH", amount_usd=Decimal("1000")),
    Intent.supply(protocol="aave_v3", token="WETH", amount="all"),  # Uses swap output
])
```

<!-- almanak-sdk-end: intent-vocabulary -->

<!-- almanak-sdk-start: market-snapshot-api -->

## Market Data API

The `MarketSnapshot` passed to `decide()` provides these methods:

### Prices

```python
price = market.price("WETH")                    # Decimal, USD price
price = market.price("WETH", quote="USDC")      # Price in USDC terms

pd = market.price_data("WETH")                  # PriceData object
pd.price             # Decimal - current price
pd.price_24h_ago     # Decimal
pd.change_24h_pct    # Decimal
pd.high_24h          # Decimal
pd.low_24h           # Decimal
pd.timestamp         # datetime
```

For a non-crypto reference such as XAU/USD, use the dedicated exact-feed API. Never substitute
`market.price("XAU")`: generic token pricing does not carry the required feed identity or market
session state.

```python
reference = market.reference_price("XAU", chain="bsc", quote="USD")
reason = reference.trade_block_reason(max_age_seconds=300, min_confidence=0.90)
if reason is not None:
    return Intent.hold(reason=reason)

reference.price                 # Decimal | None
reference.source                # exact provider/feed identity
reference.observed_at           # provider observation time, not gateway receipt time
reference.market_status         # OPEN | CLOSED | UNKNOWN
reference.market_status_as_of   # time at which the gateway evaluated the session
reference.stale                 # provider heartbeat result
```

The API returns a typed unavailable result rather than an inferred value. `is_tradeable(...)` and
`trade_block_reason(...)` fail closed for unavailable, malformed, stale, closed/unknown-session,
future-dated, over-age, or low-confidence observations.

### Balances

```python
bal = market.balance("USDC")
bal.balance       # Decimal - token amount
bal.balance_usd   # Decimal - USD value
bal.symbol        # str
bal.address       # str - token contract address
```

`TokenBalance` supports numeric comparisons: `bal > Decimal("100")`.

### Technical Indicators

All indicators accept `token`, `period` (int), and `timeframe` (str, default `"4h"`).

```python
rsi = market.rsi("WETH", period=14, timeframe="4h")
rsi.value          # Decimal (0-100)
rsi.is_oversold    # bool (value < 30)
rsi.is_overbought  # bool (value > 70)
rsi.signal         # "BUY" | "SELL" | "HOLD"

macd = market.macd("WETH", fast_period=12, slow_period=26, signal_period=9)
macd.macd_line     # Decimal
macd.signal_line   # Decimal
macd.histogram     # Decimal
macd.is_bullish_crossover  # bool
macd.is_bearish_crossover  # bool

bb = market.bollinger_bands("WETH", period=20, std_dev=2.0)
bb.upper_band      # Decimal
bb.middle_band     # Decimal
bb.lower_band      # Decimal
bb.bandwidth        # Decimal
bb.percent_b        # Decimal (0.0 = at lower band, 1.0 = at upper band)
bb.is_squeeze       # bool

stoch = market.stochastic("WETH", k_period=14, d_period=3)
stoch.k_value       # Decimal
stoch.d_value       # Decimal
stoch.is_oversold   # bool
stoch.is_overbought # bool

atr_val = market.atr("WETH", period=14)
atr_val.value       # Decimal (absolute)
atr_val.value_percent  # Decimal, percentage points (2.62 means 2.62%, not 0.0262)
atr_val.is_high_volatility  # bool

sma = market.sma("WETH", period=20)
ema = market.ema("WETH", period=12)
# Both return MAData with: .value, .is_price_above, .is_price_below, .signal

adx = market.adx("WETH", period=14)
adx.value           # Decimal
adx.plus_di         # Decimal
adx.minus_di        # Decimal
adx.is_trending     # bool
adx.is_uptrend      # bool

obv = market.obv("WETH", signal_period=21)
obv.value           # Decimal
obv.signal          # Decimal
obv.is_bullish      # bool

cci = market.cci("WETH", period=20)
cci.value           # Decimal
cci.is_overbought   # bool
cci.is_oversold     # bool

ich = market.ichimoku("WETH", tenkan_period=9, kijun_period=26, senkou_b_period=52)
ich.tenkan_sen      # Decimal (conversion line)
ich.kijun_sen       # Decimal (base line)
ich.senkou_span_a   # Decimal (leading span A)
ich.senkou_span_b   # Decimal (leading span B)
ich.is_bullish_crossover  # bool
ich.is_above_cloud  # bool
```

### Multi-Token Queries

```python
# Per-token reads work today on every deployment surface.
weth_price = market.price("WETH")                   # Decimal
wbtc_price = market.price("WBTC")                   # Decimal
usdc_bal = market.balance("USDC")                   # TokenBalance
weth_bal = market.balance("WETH")                   # TokenBalance
usd_val = market.balance_usd("WETH")                # Decimal - USD value of holdings
total = market.total_portfolio_usd()                # Decimal
```

> **Batch helpers are Phase 2.** The deprecated data-layer class exposes
> `market.prices([...])` / `market.balances([...])` batch fetchers. The
> canonical strategy-facing class deliberately does NOT lift these names
> in the ALM-2696 fix because legacy callers (runner_state.py, trust
> tests) historically used `hasattr(market, "prices")` /
> `market.prices.get(...)` patterns whose absence was load-bearing.
> Phase 2 ([VIB-4065](https://linear.app/almanak/issue/VIB-4065) /
> [GH#2126](https://github.com/almanak-co/almanak-sdk-private/issues/2126))
> migrates those callers in lockstep before lifting these batch names.
> Use the per-token form above until then.

```python
# USD value of an arbitrary collateral amount (for perp position sizing)
col_usd = market.collateral_value_usd("WETH", Decimal("2"))  # Decimal - amount * price
```

### OHLCV Data

```python
df = market.ohlcv("WETH", timeframe="1h", limit=100)  # pd.DataFrame
# Columns: open, high, low, close, volume
```

### Pool and DEX Data

> Use these (not `market.price()`) for anything execution-facing — LP range
> bounds, range-exit tests. `market.price()` is a USD valuation oracle
> (hardcoded `1.0` for stablecoins) and can silently diverge from the pool's
> actual price. See "LP Rebalancing" above.

```python
pool = market.pool_price("0x...")                   # DataEnvelope[PoolPrice]
pool = market.pool_price_by_pair("WETH", "USDC")   # DataEnvelope[PoolPrice]
reserves = market.pool_reserves("0x...")            # PoolReserves
history = market.pool_history("0x...", resolution="1h", protocol="uniswap_v3")  # DataEnvelope[list[PoolSnapshot]]
# `protocol` is REQUIRED keyword-only (VIB-4755 D-2 — closes silent cross-protocol surface).
# Must match the pool's actual protocol slug: "uniswap_v3", "aerodrome", "pancakeswap_v3", etc.
# A defaulted protocol on a non-uniswap_v3 pool address would have routed through
# CoinGecko Onchain (which does not filter on protocol slug) and silently labelled the
# served data with the wrong protocol — see docs/internal/uat-cards/VIB-4755.md §D-2.
analytics = market.pool_analytics("0x...")          # DataEnvelope[PoolAnalytics]
best = market.best_pool("WETH", "USDC", metric="fee_apr")  # DataEnvelope[PoolAnalyticsResult]
```

> **Provider availability:** `pool_*`, `twap` / `lwap`, `liquidity_depth`,
> `estimate_slippage`, `pool_analytics` / `best_pool`, `il_exposure` /
> `projected_il`, `realized_vol` / `vol_cone`, `portfolio_risk` /
> `rolling_sharpe`, `yield_opportunities`, `lst_*`, prediction-market
> methods, and the rate-history methods are all **provider-driven**. The
> runner wires the corresponding provider (pool reader registry, price
> aggregator, IL calculator, …); when a provider is not wired the method
> raises `ValueError("No <X> configured for MarketSnapshot")` rather than
> returning silently. Do **not** guard these calls with `hasattr(market,
> ...)` — the methods always exist; catch `ValueError` (or one of the
> typed `*UnavailableError` subclasses defined in
> `almanak.framework.data.market_snapshot`) if you need to degrade
> gracefully.
>
> **Carve-out — `prediction_price()`:** unlike the other prediction-market
> methods (`prediction()`, `prediction_positions()`, `prediction_orders()`,
> all of which raise `ValueError` when no provider is wired),
> `prediction_price()` returns `None` as a soft-signal fallback. Strategies
> that use it as a side-channel signal can therefore branch on
> `if (p := market.prediction_price(...)) is not None:` instead of
> wrapping the call in `try / except ValueError`. This matches the
> existing convention preserved by ALM-2696 and is pinned by the
> regression suite.

### Price Aggregation and Slippage

```python
twap = market.twap("WETH/USDC", window_seconds=300)       # DataEnvelope[AggregatedPrice]
# Explicit-pool form: when you pass `pool_address` directly, you must also
# pass token decimals (or have a `pool_reader_registry` wired so the snapshot
# can resolve them automatically). There is no "WETH/USDC default" — the
# decimals are required for the tick-to-price conversion.
twap = market.twap(
    "WBTC/WETH",
    pool_address="0x...",
    token0_decimals=8, token1_decimals=18,
)
lwap = market.lwap("WETH/USDC")                           # DataEnvelope[AggregatedPrice]
depth = market.liquidity_depth("0x...")                    # DataEnvelope[LiquidityDepth]
slip = market.estimate_slippage("WETH", "USDC", Decimal("10000"))  # DataEnvelope[SlippageEstimate]
# Canonical fields live on slip.value and are integer basis points:
if not slip.value.within_limits(max_slippage_bps=75, max_price_impact_bps=100):
    return Intent.hold(reason="pre-trade slippage or price impact exceeds limit")
prices = market.price_across_dexs("WETH", "USDC", Decimal("1"))   # list[DexQuote]
best_dex = market.best_dex_price("WETH", "USDC", Decimal("1"))    # BestDexResult
```

For an approved-pool strategy, pin every execution-facing read to the same address. A useful USD
depth contract is to simulate a trade equal to the configured minimum depth and require its
effective slippage to remain inside the limit. Check both directions when both entry and exit are
possible; convert the base-side USD amount with the already-validated reference price.

```python
pool = market.pool_price(APPROVED_POOL, chain="bsc")
depth = market.liquidity_depth(APPRO

…(truncated)
