Almanak Strategy Builder
SkillCloud & infraBuild, 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.
No other account needed.
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
.envfile referenced below is for local development only and is never deployed to production environments. - Private keys in
.envare 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 toamount=(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
__init__: Extract config parameters, set up statedecide(market): Called each iteration - return an Intenton_intent_executed(intent, success, result): Optional callback after executionget_status(): Optional - return dict for monitoring dashboardssupports_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... |
|---|---|
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..."}(orQuoteAsset.token(chain_id, address)fromalmanak.core.models.quote_asset), identifying the token by its canonical(chain_id, address). Use a numericchain_idonly, 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_modepartial 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:
| 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
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_specacceptsPriceBand(lower=..., upper=...)(human prices, token1-per-token0 — the portable default, converted to ticks by each connector) orTickBand(lower=..., upper=...)(raw protocol ticks, escape hatch). Import both fromalmanak.framework.intents. The legacyrange_lower/range_upperpair is still accepted and equivalent to aPriceBand; 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_indexroutes viaremove_liquidity_one_coin;imbalanced_amountsroutes viaremove_liquidity_imbalance. They are mutually exclusive; leave bothNonefor the proportional all-coin close. Only connectors declaring thelp_close_exit_selectorscapability (currently Curve) compile them.
LP close slippage: Curve and classic Aerodrome consume
max_slippageand default to 50 bps when it is omitted. Aerodrome derives its minimum outputs from the router'squoteRemoveLiquidityresult 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_idfromlp_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 tolp_closeat teardown. The framework uses it to look up the open row inposition_registry; a mismatch (handle present but no live row) raisesRegistryAutoCollisionErrorbefore 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