Almanak Strategy Builder

SkillCloud & infra

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.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Almanak Strategy Builder skill

What this skill tells your AI

The instructions your AI receives, as published by almanak-co/sdk in skills/almanak-strategy-builder/SKILL.md and read by ahel’s review.

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.

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 section for details.

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.

Quick Start

# 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:

[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:

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 below).

# 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.

Core Concepts

IntentStrategy

All strategies inherit from IntentStrategy and implement one method:

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:

@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...
SUPPLYWITHDRAW
BORROWREPAY
LP_OPENLP_CLOSE
VAULT_DEPOSITVAULT_REDEEM
PERP_OPENPERP_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):

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).

Intent Reference

All intents are created via Intent factory methods. Import:

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:

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:

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:

IntentTypeFuture primitive
LIQUIDATELIQUIDATION
OPEN_CDPCDP
MINT_STABLECDP
REPAY_STABLECDP
CLOSE_CDPCDP

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

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.

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

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

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

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

Lending / Borrowing

Intent.supply - Deposit collateral into a lending protocol

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

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

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)

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

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

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

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
61
Forks
5
Last commit
Sep 2026
Advanced
Item type
skill
Key
almanak-strategy-builder
Source
github.com/almanak-co/sdk