| name | almanak-strategy-builder |
| description | 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. |
| metadata | {"version":"2.21.0","author":"Almanak","license":"Apache-2.0","type":"documentation"} |
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.
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
pipx install almanak
almanak strat new --template mean_reversion --name my_rsi --chain arbitrum
cd my_rsi
almanak strat run --network anvil --once
almanak strat run --once
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
uv run pytest tests/ -v
For Anvil testing, add anvil_funding to config.json so your wallet is auto-funded on fork start
(see Configuration below).
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
__init__: Extract config parameters, set up state
decide(market): Called each iteration - return an Intent
on_intent_executed(intent, success, result): Optional callback after execution
get_status(): Optional - return dict for monitoring dashboards
supports_teardown() / generate_teardown_intents(): Optional safe shutdown
@almanak_strategy Decorator
Attaches metadata used by the framework and CLI:
@almanak_strategy(
name="my_strategy",
description="What it does",
version="1.0.0",
author="Your Name",
tags=["trading", "rsi"],
supported_chains=["arbitrum"],
supported_protocols=["uniswap_v3"],
intent_types=["SWAP", "HOLD"],
default_chain="arbitrum",
quote_asset="USD",
)
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 is definition-only today: the hosted platform reads it for performance
reporting, but the SDK does not change any valuation/accounting behaviour based on it — so
adding it is always safe.
- 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. LP, 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. It is frozen at boot — not hot-reloadable.
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
Intent.registry_handle (added on BaseIntent 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
handle = "leg_narrow"
else:
commit_pct = Decimal("0.99")
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
position_id = getattr(result, "position_id", None)
if not position_id:
return
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",
to_token="WETH",
amount_usd=Decimal("1000"),
amount=Decimal("500"),
max_slippage=Decimal("0.005"),
max_price_impact=Decimal("0.30"),
protocol="uniswap_v3",
chain="arbitrum",
destination_chain="base",
)
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.
Liquidity Provision
Intent.lp_open - Open a concentrated LP position
Intent.lp_open(
pool="WETH/USDC",
amount0=Decimal("1.0"),
amount1=Decimal("2000"),
range_lower=Decimal("1800"),
range_upper=Decimal("2200"),
range_spec=None,
protocol="uniswap_v3",
chain=None,
coin_amounts=None,
max_slippage=None,
)
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",
pool="WETH/USDC",
collect_fees=True,
protocol="uniswap_v3",
amount=None,
max_slippage=None,
coin_index=None,
imbalanced_amounts=None,
)
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.
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,
market_id=None,
)
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",
market_id=None,
)
Intent.repay - Repay borrowed tokens
Intent.repay(
protocol="aave_v3",
token="USDC",
amount=Decimal("5000"),
repay_full=False,
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",
observed_hf=Decimal("1.05"),
target_hf=Decimal("1.5"),
repay_full=False,
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,
market_id=None,
is_collateral=True,
)
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
Intent.perp_close(
market="ETH/USD",
collateral_token="USDC",
is_long=True,
size_usd=None,
max_slippage=Decimal("0.01"),
protocol="gmx_v2",
position_id=None,
)
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).
Intent.perp_withdraw(
amount=Decimal("6.99"),
asset="USDC",
protocol="hyperliquid",
chain="hyperevm",
destination=None,
)
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.
Intent.perp_cancel_order(
order_key="0x...",
protocol="gmx_v2",
chain=None,
)
Bridging
Intent.bridge - Cross-chain token transfer
Intent.bridge(
token="USDC",
amount=Decimal("1000"),
from_chain="arbitrum",
to_chain="base",
max_slippage=Decimal("0.005"),
preferred_bridge=None,
)
Staking
Intent.stake - Liquid staking deposit
Intent.stake(
protocol="lido",
token_in="ETH",
amount=Decimal("10"),
receive_wrapped=True,
)
Intent.unstake - Withdraw from liquid staking
Intent.unstake(
protocol="lido",
token_in="wstETH",
amount=Decimal("10"),
protocol_params=None,
)
Flash Loans
Intent.flash_loan - Borrow and repay in a single transaction
Intent.flash_loan(
provider="aave",
token="USDC",
amount=Decimal("100000"),
callback_intents=[...],
)
Vaults (ERC-4626)
Intent.vault_deposit - Deposit into an ERC-4626 vault
Intent.vault_deposit(
protocol="metamorpho",
vault_address="0x...",
amount=Decimal("1000"),
deposit_token="USDC",
chain="ethereum",
)
Intent.vault_redeem - Redeem shares from an ERC-4626 vault
Intent.vault_redeem(
protocol="metamorpho",
vault_address="0x...",
shares=Decimal("1000"),
deposit_token="USDC",
chain="ethereum",
)
Prediction Markets
Intent.prediction_buy(
market_id="will-bitcoin-exceed-100000",
outcome="YES",
amount_usd=Decimal("100"),
protocol="polymarket",
)
Intent.prediction_sell(
market_id="will-bitcoin-exceed-100000",
outcome="YES",
shares=Decimal("50"),
protocol="polymarket",
)
Intent.prediction_redeem(
market_id="will-bitcoin-exceed-100000",
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().
intent = Intent.ensure_balance(
token="USDC",
min_amount=Decimal("1000"),
target_chain="arbitrum",
max_slippage=Decimal("0.005"),
preferred_bridge=None,
)
resolved = intent.resolve(market)
return resolved
Token Utilities
Intent.wrap (WrapNative) - Wrap native tokens to ERC-20 (ETH -> WETH, MATIC -> WMATIC, etc.)
Intent.wrap(
token="WETH",
amount=Decimal("0.5"),
chain="arbitrum",
)
Intent.unwrap (UnwrapNative) - Unwrap wrapped native tokens (WETH -> ETH, WMATIC -> MATIC, etc.)
Intent.unwrap(
token="WETH",
amount=Decimal("0.5"),
chain="arbitrum",
)
Control Flow
Intent.hold - Do nothing this iteration
Intent.hold(reason="RSI in neutral zone")
Intent.sequence - Execute multiple intents in order
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:
Intent.sequence(intents=[
Intent.swap(from_token="USDC", to_token="WETH", amount_usd=Decimal("1000")),
Intent.supply(protocol="aave_v3", token="WETH", amount="all"),
])
Market Data API
The MarketSnapshot passed to decide() provides these methods:
Prices
price = market.price("WETH")
price = market.price("WETH", quote="USDC")
pd = market.price_data("WETH")
pd.price
pd.price_24h_ago
pd.change_24h_pct
pd.high_24h
pd.low_24h
pd.timestamp
Balances
bal = market.balance("USDC")
bal.balance
bal.balance_usd
bal.symbol
bal.address
TokenBalance supports numeric comparisons: bal > Decimal("100").
Technical Indicators
All indicators accept token, period (int), and timeframe (str, default "4h").
rsi = market.rsi("WETH", period=14, timeframe="4h")
rsi.value
rsi.is_oversold
rsi.is_overbought
rsi.signal
macd = market.macd("WETH", fast_period=12, slow_period=26, signal_period=9)
macd.macd_line
macd.signal_line
macd.histogram
macd.is_bullish_crossover
macd.is_bearish_crossover
bb = market.bollinger_bands("WETH", period=20, std_dev=2.0)
bb.upper_band
bb.middle_band
bb.lower_band
bb.bandwidth
bb.percent_b
bb.is_squeeze
stoch = market.stochastic("WETH", k_period=14, d_period=3)
stoch.k_value
stoch.d_value
stoch.is_oversold
stoch.is_overbought
atr_val = market.atr("WETH", period=14)
atr_val.value
atr_val.value_percent
atr_val.is_high_volatility
sma = market.sma("WETH", period=20)
ema = market.ema("WETH", period=12)
adx = market.adx("WETH", period=14)
adx.value
adx.plus_di
adx.minus_di
adx.is_trending
adx.is_uptrend
obv = market.obv("WETH", signal_period=21)
obv.value
obv.signal
obv.is_bullish
cci = market.cci("WETH", period=20)
cci.value
cci.is_overbought
cci.is_oversold
ich = market.ichimoku("WETH", tenkan_period=9, kijun_period=26, senkou_b_period=52)
ich.tenkan_sen
ich.kijun_sen
ich.senkou_span_a
ich.senkou_span_b
ich.is_bullish_crossover
ich.is_above_cloud
Multi-Token Queries
weth_price = market.price("WETH")
wbtc_price = market.price("WBTC")
usdc_bal = market.balance("USDC")
weth_bal = market.balance("WETH")
usd_val = market.balance_usd("WETH")
total = market.total_portfolio_usd()
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 /
GH#2126)
migrates those callers in lockstep before lifting these batch names.
Use the per-token form above until then.
col_usd = market.collateral_value_usd("WETH", Decimal("2"))
OHLCV Data
df = market.ohlcv("WETH", timeframe="1h", limit=100)
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.
pool = market.pool_price("0x...")
pool = market.pool_price_by_pair("WETH", "USDC")
reserves = market.pool_reserves("0x...")
history = market.pool_history("0x...", resolution="1h", protocol="uniswap_v3")
analytics = market.pool_analytics("0x...")
best = market.best_pool("WETH", "USDC", metric="fee_apr")
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
twap = market.twap("WETH/USDC", window_seconds=300)
twap = market.twap(
"WBTC/WETH",
pool_address="0x...",
token0_decimals=8, token1_decimals=18,
)
lwap = market.lwap("WETH/USDC")
depth = market.liquidity_depth("0x...")
slip = market.estimate_slippage("WETH", "USDC", Decimal("10000"))
prices = market.price_across_dexs("WETH", "USDC", Decimal("1"))
best_dex = market.best_dex_price("WETH", "USDC", Decimal("1"))
Explicit-pool decimals contract (twap): any call that supplies
pool_address directly must either pass
token0_decimals / token1_decimals explicitly OR have a
pool_reader_registry wired on the snapshot so the decimals can be
resolved from pool metadata. There is no "WETH/USDC fallback" — the
tick-to-price conversion needs the real decimals. Without either path,
the call raises ValueError rather than returning a price that can be
off by powers of ten for pools like WBTC/WETH (8/18) or USDC/USDT
(6/6). lwap does not accept pool_address and is unaffected (it
scans pools internally via the registry).
Lending and Funding Rates
rate = market.lending_rate("aave_v3", "USDC", side="supply")
best = market.best_lending_rate("USDC", side="supply")
fr = market.funding_rate("binance", "ETH-PERP")
spread = market.funding_rate_spread("ETH-PERP", "binance", "hyperliquid")
Impermanent Loss
il = market.il_exposure("position_id", fees_earned=Decimal("50"))
proj = market.projected_il("WETH", "USDC", price_change_pct=Decimal("0.1"))
Prediction Markets
mkt = market.prediction("market_id")
price = market.prediction_price("market_id", "YES")
positions = market.prediction_positions("market_id")
orders = market.prediction_orders("market_id")
Rate History (Backtesting)
hist = market.lending_rate_history("aave_v3", "USDC", days=90)
for snap in hist.value:
print(f"Supply: {snap.supply_apy}%, Borrow: {snap.borrow_apy}%")
fh = market.funding_rate_history("binance", "ETH-PERP", hours=168)
Position Health
ph = market.position_health("morpho_blue", market_id="0x...")
ph.health_factor
ph.ltv
pt = market.pt_position_health("0x...", principal_token_market_address="0x...")
LST Exchange Rates (Solana)
rate = market.lst_exchange_rate("jitoSOL")
rate.rate
rate.apy
all_rates = market.lst_all_rates()
Risk Metrics
vol = market.realized_vol("WETH", window_days=30)
cone = market.vol_cone("WETH")
risk = market.portfolio_risk(pnl_series)
sharpe = market.rolling_sharpe(pnl_series, window_days=30)
Yield and Analytics
yields = market.yield_opportunities("USDC", min_tvl=100_000, sort_by="apy")
gas = market.gas_price()
health = market.health()
signals = market.wallet_activity(action_types=["SWAP", "LP_OPEN"])
Context Properties
market.chain
market.wallet_address
market.timestamp
market.fork_rpc_url
market.fork_block
Critical Data Failure Tracking
The runner uses these methods to detect when a strategy returns HOLD while market-data lookups
were failing (e.g. price oracle timeouts, unknown tokens), and escalates those cycles into
IterationStatus.DATA_ERROR so the consecutive-error circuit breaker fires correctly.
market.has_critical_data_failures()
market.critical_data_failure_count()
market.classify_critical_data_failures()
market.summarize_critical_data_failures()
market.clear_critical_data_failures()
State Management
The framework automatically persists runner-level metadata (iteration counts, error counters,
multi-step execution progress) after each iteration. However, strategy-specific state --
position IDs, trade counts, phase tracking, cooldown timers -- is only persisted if you implement
two hooks: get_persistent_state() and load_persistent_state().
Without these hooks, all instance variables are lost on restart. This is especially dangerous for
LP and lending strategies where losing a position ID means the strategy cannot close its own
positions.
Required for any stateful strategy:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._position_id: int | None = None
self._phase: str = "idle"
self._entry_price: Decimal = Decimal("0")
def get_persistent_state(self) -> dict:
"""Called by framework after each iteration to serialize state for persistence."""
return {
"position_id": self._position_id,
"phase": self._phase,
"entry_price": str(self._entry_price),
}
def load_persistent_state(self, saved: dict) -> None:
"""Called by framework on startup to restore state from previous run."""
self._position_id = saved.get("position_id")
self._phase = saved.get("phase", "idle")
self._entry_price = Decimal(saved.get("entry_price", "0"))
Guidelines:
- Use defensive
.get() with defaults in load_persistent_state() so older saved state doesn't
crash when you add new fields.
- Store
Decimal values as strings (str(amount)) and parse back (Decimal(state["amount"]))
for safe JSON round-tripping. All values must be JSON-serializable.
- The
on_intent_executed() callback is the natural place to update state after a trade (e.g.,
storing a new position ID), and get_persistent_state() then picks it up for saving.
Use --fresh to clear saved state when starting over: almanak strat run --fresh --once.
on_intent_executed Callback
After execution, access results (position IDs, swap amounts) via the callback. The framework
automatically enriches result with protocol-specific data - no manual receipt parsing needed.
def on_intent_executed(self, intent, success: bool, result):
if not success:
logger.warning(f"Intent failed: {intent.intent_type}")
return
if result.position_id is not None:
self._lp_position_id = result.position_id
logger.info(f"Opened LP position {result.position_id}")
if (
hasattr(intent, "range_lower") and intent.range_lower is not None
and hasattr(intent, "range_upper") and intent.range_upper is not None
):
self._range_lower = intent.range_lower
self._range_upper = intent.range_upper
if result.swap_amounts:
self._last_swap = {
"amount_in": str(result.swap_amounts.amount_in),
"amount_out": str(result.swap_amounts.amount_out),
}
logger.info(
f"Swapped {result.swap_amounts.amount_in} -> {result.swap_amounts.amount_out}"
)
Configuration
config.json
Contains the target chain and tunable runtime parameters. name, description, and
supported_chains still live in the @almanak_strategy decorator on your strategy class; the
config.json chain field acts as an explicit override of the decorator's default_chain and
lets tooling (sdk-planner, operators, deployment UIs) read the target chain without importing
the strategy module.
Single-chain:
{
"chain": "arbitrum",
"base_token": "WETH",
"quote_token": "USDC",
"rsi_period": 14,
"rsi_oversold": 30,
"rsi_overbought": 70,
"trade_size_usd": 1000,
"max_slippage_bps": 50,
"anvil_funding": {
"USDC": "10000",
"WETH": "5"
}
}
Multi-chain:
{
"chains": ["base", "arbitrum"],
"swap_amount_usdc": "100",
"max_slippage_bps": 100,
"anvil_funding": {
"USDC": 500
}
}
The chains field lists the chains the strategy operates on and is read by the platform at
deployment time. It should match supported_chains from the @almanak_strategy decorator.
For single-chain strategies, chain (singular) is also accepted. anvil_funding is flat --
the same tokens are funded on all chains.
All other fields are strategy-specific and accessed via self.config.get(key, default).
.env (local development only)
Security note: The .env file is for local development and Anvil fork testing only.
In production, secrets are managed by the platform and injected into the gateway sidecar -
they never reach the strategy container. See Security Model.
ALMANAK_PRIVATE_KEY=<your-private-key>
ALCHEMY_API_KEY=<your-alchemy-key>
token_funding (recommended, will be required)
Structured list declaring exactly which tokens the strategy needs to be funded before the first tick.
Each entry specifies the token symbol, on-chain address, amount, and how to interpret the amount.
strat new generates this automatically when the template includes token fields.
"token_funding": [
{"symbol": "WETH", "address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "chain": "arbitrum", "amount": "1", "amount_type": "token"},
{"symbol": "USDC", "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "amount": "5000", "amount_type": "usd"}
]
| Field | Required | Description |
|---|
symbol | yes | Token symbol (e.g. "WETH") |
address | yes | ERC-20 contract address |
chain | no | Defaults to strategy chain |
amount | yes | Quantity (string to preserve precision) |
amount_type | yes | "token" (native units), "usd" (dollar value), or "percentage" (of held balance) |
anvil_funding
When running on Anvil (--network anvil), the framework auto-funds the wallet
with tokens specified in anvil_funding. Values are in token units (not USD).
Token Resolution
Use get_token_resolver() for all token lookups. Never hardcode addresses.
from almanak.framework.data.tokens import get_token_resolver
resolver = get_token_resolver()
token = resolver.resolve("USDC", "arbitrum")
token = resolver.resolve("0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "arbitrum")
decimals = resolver.get_decimals("arbitrum", "USDC")
address = resolver.get_address("arbitrum", "USDC")
token = resolver.resolve_for_swap("ETH", "arbitrum")
usdc, weth = resolver.resolve_pair("USDC", "WETH", "arbitrum")
Resolution order: memory cache -> disk cache -> static registry -> gateway on-chain lookup.
Never default to 18 decimals. If the token is unknown, TokenNotFoundError is raised.
Backtesting
PnL Backtest (historical prices, no on-chain execution)
almanak strat backtest pnl -s my_strategy \
--start 2024-01-01 --end 2024-06-01 \
--initial-capital 10000
Paper Trading (Anvil fork with real execution, PnL tracking)
almanak strat backtest paper -s my_strategy \
--duration 3600 --interval 60 \
--initial-capital 10000
Paper trading runs the full strategy loop on an Anvil fork with real transaction
execution, equity curve tracking, and JSON result logs.
Parameter Sweep
almanak strat backtest sweep -s my_strategy \
--start 2024-01-01 --end 2024-06-01 \
--param "rsi_oversold:20,25,30" \
--param "rsi_overbought:70,75,80"
Runs the PnL backtest across all parameter combinations and ranks by Sharpe ratio.
Programmatic Backtesting
from almanak.framework.backtesting import BacktestEngine
engine = BacktestEngine(
strategy_class=MyStrategy,
config={...},
start_date="2024-01-01",
end_date="2024-06-01",
initial_capital=10000,
)
results = engine.run()
results.sharpe_ratio
results.max_drawdown
results.total_return
results.plot()
Backtesting Limitations
- OHLCV data: The PnL backtester uses historical close prices from CoinGecko. Indicators that require OHLCV data (ATR, Stochastic, Ichimoku) need a paid CoinGecko tier or an external data source.
- RPC for paper trading: Paper trading requires an RPC endpoint. Alchemy free tier is recommended for performance; public RPCs work but are slow.
- No CWD auto-discovery: Backtest CLI commands (
backtest pnl, backtest paper, backtest sweep) require an explicit -s strategy_name flag. They do not auto-discover strategies from the current directory like strat run does.
- Percentage fields:
total_return_pct and annualized_return_pct are actual percentages (33 = 33%) after VIB-2915. Other _pct fields like max_drawdown_pct and win_rate are still decimal fractions (0.33 = 33%).
CLI Commands
Strategy Management
almanak strat new
almanak strat new -t ta_swap -n my_rsi -c arbitrum
almanak strat demo
Templates:
| Template | Description |
|---|
blank | Minimal scaffold for custom implementations |
ta_swap | Technical-analysis swap strategy (RSI, Bollinger Bands, or combined signals) |
dynamic_lp | Price-based LP range management with rebalancing |
lending_loop | Supply/borrow leverage loop with state machine and health monitoring |
basis_trade | Spot+perp delta-neutral funding-rate arbitrage |
vault_yield | ERC-4626 vault deposit/redeem yield strategy |
copy_trader | Monitor leader wallets and replicate trades |
perps | Perpetual futures with take-profit / stop-loss |
multi_step | Atomic multi-step operations using IntentSequence (e.g., LP rebalancing) |
staking | Liquid staking with optional pre-swap |
Each scaffolded strategy is a self-contained Python project. After scaffolding, uv sync runs
automatically to create .venv/ and uv.lock. Add dependencies with uv add <package>.
Running Strategies
almanak strat run --once
almanak strat run -d path/to/strat --once
almanak strat run --network anvil --once
almanak strat run --interval 30
almanak strat run --dry-run --once
almanak strat run --fresh --once
almanak strat run --id abc123 --once
almanak strat run --dashboard
Backtesting
almanak strat backtest pnl -s my_strategy
almanak strat backtest paper -s my_strategy
almanak strat backtest sweep -s my_strategy
Teardown
almanak strat teardown plan
almanak strat teardown execute
Permissions
almanak strat permissions
almanak strat permissions -o permissions.json
almanak strat permissions -d path/to/strat
almanak strat permissions --chain base
Generates a JSON manifest of minimum-privilege contract permissions needed for Safe wallet deployments with Zodiac Roles. Reads supported_protocols and intent_types from @almanak_strategy metadata and compiles synthetic intents to discover required contract addresses and function selectors. Non-EVM chains are automatically skipped. The default output format is Zodiac Roles Target[].
Gateway
almanak gateway
almanak gateway --network anvil
almanak gateway --port 50052
Agent Skill Management
almanak agent install
almanak agent install -p claude
almanak agent install -p all
almanak agent update
almanak agent status
Strategy Operations
almanak strat list
almanak strat status
almanak strat logs
almanak strat pause
almanak strat resume
Copy Trading
almanak copy validate
almanak copy replay
almanak copy report
Services & Tools
almanak ax
almanak backtest-service
almanak dashboard
almanak mcp serve
almanak info matrix
Documentation
almanak docs path
almanak docs dump
almanak docs agent-skill
almanak docs agent-skill --dump
Zodiac Permissions
Every strategy deployed on a Safe wallet uses Zodiac Roles to enforce minimum-privilege access. The permissions system automatically discovers which contracts and function selectors the strategy needs by compiling synthetic intents.
When to Generate
Regenerate permissions whenever you:
- Create a new strategy
- Add or remove protocols in
@almanak_strategy(supported_protocols=[...])
- Add or remove intent types in
@almanak_strategy(intent_types=[...])
- Change tokens in
config.json (base_token, quote_token, collateral_token, etc.)
- Add or remove chains in
@almanak_strategy(supported_chains=[...])
How It Works
- Reads
supported_protocols and intent_types from the @almanak_strategy() decorator
- Auto-expands teardown complements (e.g. SUPPLY adds WITHDRAW) so teardown permissions are always included
- Creates synthetic intents for each (protocol, intent_type) pair
- Compiles them through the real IntentCompiler to extract target contracts and selectors
- Adds ERC-20
approve permissions for tokens found in config.json
- Adds infrastructure permissions (MultiSend for atomic execution)
- Merges, deduplicates, and outputs as Zodiac Roles Target[] format
Usage
almanak strat permissions -o permissions.json
almanak strat permissions
almanak strat permissions --chain arbitrum -o permissions.json
Output Format
The Zodiac Roles Target[] format is a JSON array ready for Safe wallet configuration:
[
{
"address": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
"clearance": 2,
"executionOptions": 0,
"functions": [
{ "selector": "0x04e45aaf", "wildcarded": true }
]
}
]
clearance: 2 = function-level (specific selectors), 1 = target-level (all functions)
executionOptions: 0 = None, 1 = Send, 2 = DelegateCall, 3 = Both
wildcarded: true means the selector applies regardless of input arguments
Strategy Decorator Requirements
For permissions to generate correctly, ensure your @almanak_strategy decorator declares all protocols and intent types:
@almanak_strategy(
name="my_strategy",
default_chain="arbitrum",
supported_chains=["arbitrum", "base"],
supported_protocols=["uniswap_v3", "aave_v3"],
intent_types=["SWAP", "SUPPLY", "WITHDRAW", "BORROW", "REPAY"],
)
class MyStrategy(IntentStrategy):
...
Supported Chains and Protocols
Chains
| Chain | Config Name |
|---|
| Ethereum | ethereum |
| Arbitrum | arbitrum |
| Optimism | optimism |
| Base | base |
| Avalanche | avalanche |
| Polygon | polygon |
| BSC | bsc |
| Sonic | sonic |
| Plasma | plasma |
| Blast | blast |
| Linea | linea |
| Mantle | mantle |
| Berachain | berachain |
| Monad | monad |
| X-Layer | xlayer |
| 0G Chain | zerog |
| HyperEVM | hyperevm |
| Robinhood Chain | robinhood |
| Solana | solana |
Protocols
| Protocol | Enum Value | Type | Config Name |
|---|
| Uniswap V3 | UNISWAP_V3 | DEX / LP | uniswap_v3 |
| Uniswap V4 | UNISWAP_V4 | DEX / LP | uniswap_v4 |
| PancakeSwap V3 | PANCAKESWAP_V3 | DEX / LP | pancakeswap_v3 |
| SushiSwap V3 | SUSHISWAP_V3 | DEX / LP | sushiswap_v3 |
| TraderJoe V2 | TRADERJOE_V2 | DEX / LP | traderjoe_v2 |
| Aerodrome | AERODROME | DEX / LP | aerodrome |
| Agni Finance | AGNI_FINANCE | DEX / LP | agni_finance |
| Enso | ENSO | Aggregator | enso |
| Pendle | PENDLE | Yield | pendle |
| MetaMorpho | METAMORPHO | Lending | metamorpho |
| LiFi | LIFI | Bridge | lifi |
| BenQi | BENQI | Lending | benqi |
| Joe Lend (DORMANT) | JOE_LEND | Lending — wound down on-chain (VIB-3960); compiler short-circuits. Do NOT use. | joelend |
| Silo V2 | SILO_V2 | Lending | silo_v2 |
| Euler V2 | EULER_V2 | Lending | euler_v2 |
| Vault | VAULT | ERC-4626 | vault |
| Curve | CURVE | DEX / LP | curve |
| Balancer V2 | BALANCER | DEX / LP | balancer_v2 |
| Aave V3 | * | Lending | aave_v3 |
| Morpho Blue | * | Lending | morpho_blue |
| Compound V3 | * | Lending | compound_v3 |
| GMX V2 | * | Perps | gmx_v2 |
| Hyperliquid | * | Perps | hyperliquid |
| Polymarket | * | Prediction | polymarket |
| Kraken | * | CEX | kraken |
| Lido | * | Staking | lido |
| Lagoon | * | Vault | lagoon |
* These protocols do not have a Protocol enum value. Use the string config name (e.g., protocol="aave_v3") in intents. They are resolved by the intent compiler and transaction builder directly.
Additional Connectors
The connector registry (ConnectorRegistry.all()) is the post-VIB-4298 source of truth. Connectors below have full SDK + adapter + receipt-parser implementations and are routable via the protocol="<name>" string in intents, but do not have a top-level Protocol enum entry. Use the canonical name listed in Config Name:
| Connector | Type | Config Name | Chains |
|---|
| Across | Bridge | across | Ethereum, Arbitrum, Optimism, Base, Polygon |
| Aster Perps | Perp | aster_perps | BNB Chain |
| Camelot | DEX | camelot | Arbitrum |
| Curvance | Lending | curvance | Monad |
| Drift | Perp | drift | Solana |
| Ethena | Staking | ethena | Ethereum |
| Fluid | DEX | fluid | Arbitrum |
| Fluid DEX LP | DEX / LP | fluid_dex_lp | Arbitrum |
| Fluid Vault | Lending | fluid_vault | Arbitrum, Base |
| Gimo | Staking | gimo | 0G |
| Jupiter | DEX | jupiter | Solana |
| Jupiter Lend | Lending | jupiter_lend | Solana |
| Kamino | Lending | kamino | Solana |
| Meteora | DEX | meteora | Solana |
| Morpho Vault | Vault | morpho_vault | Ethereum, Base |
| Orca | DEX | orca | Solana |
| PancakeSwap Perps | Perp | pancakeswap_perps | BNB Chain |
| Raydium | DEX | raydium | Solana |
| Spark | Lending | spark | Ethereum |
| Stargate | Bridge | stargate | Ethereum, Arbitrum, Optimism, Base, Polygon, BNB Chain, Avalanche |
For the full machine-readable inventory (every connector, every intent it implements, every chain it ships on), see the generated matrix at docs/api/connectors/index.md.
Networks
| Network | Enum Value | Description |
|---|
| Mainnet | MAINNET | Production chains |
| Anvil | ANVIL | Local fork for testing |
| Sepolia | SEPOLIA | Testnet |
Protocol-Specific Notes
GMX V2 (Perpetuals)
- Market format: Use slash separator:
"BTC/USD", "ETH/USD", "LINK/USD" (not dash).
- Two-step execution: GMX V2 uses a keeper-based execution model. When you call
Intent.perp_open(), the SDK submits an order creation transaction. A GMX keeper then executes the actual position change in a separate transaction. on_intent_executed(success=True) fires when the order creation TX confirms, not when the keeper executes the position. Strategies should poll position state before relying on it.
- Minimum position size: GMX V2 enforces a minimum position size of approximately $11 net of fees. Orders below this threshold are silently rejected by the keeper with no on-chain error.
- Collateral approvals: Handled automatically by the intent compiler (same as LP opens).
- Position monitoring:
get_all_positions() may not return positions immediately after opening due to keeper delay. Allow a few seconds before querying.
- Supported chains: Arbitrum, Avalanche.
- Collateral tokens: USDC, USDT (chain-dependent).
Common Patterns
RSI Mean Reversion (Trading)
def decide(self, market):
rsi = market.rsi(self.base_token, period=self.rsi_period)
quote_bal = market.balance(self.quote_token)
base_bal = market.balance(self.base_token)
if rsi.is_oversold and quote_bal.balance_usd >= self.trade_size:
return Intent.swap(
from_token=self.quote_token, to_token=self.base_token,
amount_usd=self.trade_size, max_slippage=Decimal("0.005"),
)
if rsi.is_overbought and base_bal.balance_usd >= self.trade_size:
return Intent.swap(
from_token=self.base_token, to_token=self.quote_token,
amount_usd=self.trade_size, max_slippage=Decimal("0.005"),
)
return Intent.hold(reason=f"RSI={rsi.value:.1f} in neutral zone")
LP Rebalancing
Use pool_price / pool_price_by_pair, never market.price(), for
concentrated-LP range bounds or a range-exit test (VIB-exp19).
market.price() is a USD valuation oracle — it returns a hardcoded
1.0 for stablecoins (source: stablecoin_peg) and is not guaranteed to
match the pool's actual price for any pair. A range centered on it, or a
range-exit check built from it, can silently mint out of range (zero
fees, no error) — and for a stable pair pinned at oracle 1.0, the
range-exit check below becomes a structural no-op (spot never moves, so
it's always "in range") even while the real pool has drifted outside the
position. pool_price_by_pair returns the price in the pool's own
on-chain token0/token1 order, which may not match your base/quote
order — verify orientation against a known price before trusting the
sign, or use pool_price(pool_address, chain) when you have the address.
See Blueprint 03 §"LP_OPEN range-excludes-spot warning" for the
compile-time guard that now catches this class of bug at LP_OPEN time.
def decide(self, market):
price = market.pool_price_by_pair(self.base_token, self.quote_token).price
position_id = self._lp_position_id
if position_id:
if price < self._range_lower or price > self._range_upper:
return Intent.lp_close(position_id=position_id, protocol="uniswap_v3")
atr = market.atr(self.base_token)
half_range = price * (atr.value_percent / Decimal("100")) * 2
return Intent.lp_open(
pool="WETH/USDC",
amount0=Decimal("1"), amount1=Decimal("2000"),
range_lower=price - half_range,
range_upper=price + half_range,
)
Multi-Step with IntentSequence
def decide(self, market):
return Intent.sequence(
intents=[
Intent.swap(from_token="USDC", to_token="WETH", amount_usd=Decimal("5000")),
Intent.supply(protocol="aave_v3", token="WETH", amount="all"),
Intent.borrow(
protocol="aave_v3",
collateral_token="WETH", collateral_amount=Decimal("0"),
borrow_token="USDC", borrow_amount=Decimal("3000"),
),
],
description="Leverage loop: buy WETH, supply, borrow USDC",
)
Multi-Chain Strategies
Strategies can operate across multiple chains with per-chain wallet addresses.
The primary chain is supported_chains[0]. Intents without an explicit chain=
parameter run on the primary chain.
Decorator:
@almanak_strategy(
name="cross_chain_arb",
supported_chains=["base", "arbitrum"],
supported_protocols=["uniswap_v3", "across"],
intent_types=["SWAP", "BRIDGE", "HOLD"],
)
class CrossChainArbStrategy(IntentStrategy):
...
config.json:
{
"chains": ["base", "arbitrum"],
"swap_amount_usdc": "100",
"anvil_funding": {
"USDC": 500
}
}
Note: anvil_funding is flat (not per-chain) -- the same tokens are funded on
all chains.
Strategy properties:
self.chain
self.chains
self.wallet_address
self.get_wallet_for_chain("arbitrum")
When a gateway wallet registry is configured (ALMANAK_GATEWAY_WALLETS), each
chain can use a different Safe wallet. The framework resolves destination wallets
automatically for bridge intents.
decide() with cross-chain intents:
def decide(self, market: MarketSnapshot):
return Intent.sequence([
Intent.bridge(
token="USDC",
amount=Decimal("100"),
from_chain="base",
to_chain="arbitrum",
preferred_bridge="across",
max_slippage=Decimal("0.01"),
),
Intent.swap(
from_token="USDC",
to_token="WETH",
amount=Decimal("50"),
protocol="uniswap_v3",
chain="arbitrum",
),
Intent.bridge(
token="USDC",
amount=Decimal("50"),
from_chain="arbitrum",
to_chain="base",
preferred_bridge="across",
),
], description="Arb USDC across chains")
Multi-chain market data:
For multi-chain strategies, market is a MultiChainMarketSnapshot with
chain-aware queries:
def decide(self, market):
arb_price = market.price("WETH", chain="arbitrum")
base_price = market.price("WETH", chain="base")
usdc_on_base = market.balance("USDC", chain="base")
market.healthy_chains
market.stale_chains
market.all_chains_healthy
Key rules:
- Intents on the primary chain can omit
chain= -- it's implicit
- Intents on non-primary chains must include
chain="arbitrum" etc.
- Bridge intents always require explicit
from_chain and to_chain
- Use
Intent.sequence() to order cross-chain operations
amount="all" chaining does not work after bridge intents (bridge receipt
parsers don't extract output amounts) -- use explicit amounts instead
Alerting
from almanak.framework.alerting import AlertManager
class MyStrategy(IntentStrategy):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.alerts = AlertManager.from_config(self.config.get("alerting", {}))
def decide(self, market):
rsi = market.rsi("WETH")
if rsi.value < 20:
self.alerts.send("Extreme oversold: RSI={:.1f}".format(rsi.value), level="warning")
Safe Teardown
All IntentStrategy subclasses must implement two abstract teardown methods:
get_open_positions() and generate_teardown_intents(). Without these, the
strategy class cannot be instantiated.
For strategies that never hold positions, extend StatelessStrategy instead of
IntentStrategy — it provides empty default implementations.
get_open_positions()
Returns a TeardownPositionSummary describing all current positions. Must query
on-chain state (not cached) for safety:
def get_open_positions(self):
from datetime import UTC, datetime
from almanak.framework.teardown import PositionInfo, PositionType, TeardownPositionSummary
positions = []
try:
market = self.create_market_snapshot()
base_bal = market.balance(self.base_token)
if base_bal.balance > 0:
positions.append(
PositionInfo(
position_type=PositionType.TOKEN,
position_id=f"{self.base_token}-holding",
chain=self.chain,
protocol="uniswap_v3",
value_usd=base_bal.balance_usd,
details={"asset": self.base_token, "amount": str(base_bal.balance)},
)
)
except Exception:
logger.warning("Unable to fetch balances for teardown position summary")
return TeardownPositionSummary(
deployment_id=getattr(self, "deployment_id", "my_strategy"),
timestamp=datetime.now(UTC),
positions=positions,
)
PositionType values (close in this priority order):
PERP > BORROW > SUPPLY > LP > STAKE > PREDICTION > CEX > TOKEN
For strategies with no positions, return TeardownPositionSummary.empty(self.deployment_id).
generate_teardown_intents()
Returns intents to close all positions, respecting priority order and teardown mode:
def generate_teardown_intents(self, mode, market=None) -> list[Intent]:
from almanak.framework.teardown import TeardownMode
max_slippage = Decimal("0.03") if mode == TeardownMode.HARD else Decimal("0.01")
intents = []
position_id = self._lp_position_id
if position_id:
intents.append(Intent.lp_close(position_id=position_id))
intents.append(Intent.swap(
from_token=self.base_token, to_token=self.quote_token,
amount="all", max_slippage=max_slippage,
))
return intents
TeardownMode.SOFT = graceful exit (minimize costs), TeardownMode.HARD = emergency (speed over cost).
Lending strategy teardown example (Aave V3)
def get_open_positions(self):
from datetime import UTC, datetime
from almanak.framework.teardown import PositionInfo, PositionType, TeardownPositionSummary
positions = []
if self._borrowed_amount > 0:
positions.append(PositionInfo(
position_type=PositionType.BORROW,
position_id=f"aave-borrow-{self.borrow_token}",
chain=self.chain, protocol="aave_v3",
value_usd=self._borrowed_amount * self._borrow_price,
details={"asset": self.borrow_token, "amount": str(self._borrowed_amount)},
))
if self._supplied_amount > 0:
positions.append(PositionInfo(
position_type=PositionType.SUPPLY,
position_id=f"aave-supply-{self.collateral_token}",
chain=self.chain, protocol="aave_v3",
value_usd=self._supplied_amount,
details={"asset": self.collateral_token, "amount": str(self._supplied_amount)},
))
return TeardownPositionSummary(
deployment_id=self.deployment_id, timestamp=datetime.now(UTC), positions=positions,
)
def generate_teardown_intents(self, mode, market=None) -> list[Intent]:
intents = []
if self._borrowed_amount > 0:
intents.append(Intent.repay(
protocol="aave_v3", token=self.borrow_token,
amount=self._borrowed_amount, repay_full=True, chain=self.chain,
))
if self._supplied_amount > 0:
intents.append(Intent.withdraw(
protocol="aave_v3", token=self.collateral_token,
amount=self._supplied_amount, withdraw_all=True, chain=self.chain,
))
return intents
Error Handling
Let exceptions propagate from decide(). The framework catches them and feeds
them into its built-in circuit breaker, which tracks consecutive failures and
stops the strategy after a threshold is reached.
def decide(self, market):
rsi = market.rsi("WETH", period=14)
Execution Failure Tracking (Circuit Breaker)
The framework retries each failed intent up to max_retries (default: 3) with
exponential backoff. However, after all retries are exhausted the strategy
continues running and will attempt the same trade on the next iteration.
Without a circuit breaker, this creates an infinite loop of reverted transactions
that burn gas without any hope of success.
Always track consecutive execution failures in persistent state and stop
trading (or enter an extended cooldown) after a threshold is reached:
MAX_CONSECUTIVE_FAILURES = 3
FAILURE_COOLDOWN_SECONDS = 1800
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.consecutive_failures = 0
self.failure_cooldown_until = 0.0
def decide(self, market):
try:
now = time.time()
if now < self.failure_cooldown_until:
remaining = int(self.failure_cooldown_until - now)
return Intent.hold(
reason=f"Circuit breaker active, cooldown {remaining}s remaining"
)
if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
self.failure_cooldown_until = now + FAILURE_COOLDOWN_SECONDS
self.consecutive_failures = 0
logger.warning(
f"Circuit breaker tripped after {MAX_CONSECUTIVE_FAILURES} "
f"consecutive failures, cooling down {FAILURE_COOLDOWN_SECONDS}s"
)
return Intent.hold(reason="Circuit breaker tripped")
except Exception as e:
logger.exception(f"Error in decide(): {e}")
return Intent.hold(reason=f"Error: {e}")
def on_intent_executed(self, intent, success: bool, result):
if success:
self.consecutive_failures = 0
else:
self.consecutive_failures += 1
logger.warning(
f"Intent failed ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})"
)
def get_persistent_state(self) -> dict:
return {
"consecutive_failures": self.consecutive_failures,
"failure_cooldown_until": self.failure_cooldown_until,
}
def load_persistent_state(self, state: dict) -> None:
self.consecutive_failures = int(state.get("consecutive_failures", 0))
self.failure_cooldown_until = float(state.get("failure_cooldown_until", 0))
Important: Only update trade-timing state (e.g. last_trade_ts) inside
on_intent_executed when success=True, not when the intent is created. Setting
it at creation time means a failed trade still resets the interval timer, causing
the strategy to wait before retrying — or worse, to keep retrying on a fixed
schedule with no failure awareness.
Handling Gas and Slippage Errors (Sadflow Hook)
Override on_sadflow_enter to react to specific error types during intent
retries. This hook is called before each retry attempt and lets you modify the
transaction (e.g. increase gas or slippage) or abort early:
from almanak.framework.intents.state_machine import SadflowAction
class MyStrategy(IntentStrategy):
def on_sadflow_enter(self, error_type, attempt, context):
if error_type == "INSUFFICIENT_FUNDS":
return SadflowAction.abort("Insufficient funds, stopping retries")
if error_type == "GAS_ERROR" and context.action_bundle:
modified = self._increase_gas(context.action_bundle)
return SadflowAction.modify(modified, reason="Increased gas limit")
if error_type == "SLIPPAGE" and attempt >= 1:
return SadflowAction.abort("Slippage error persists, aborting")
return None
Error types passed to on_sadflow_enter (from _categorize_error in state_machine.py):
GAS_ERROR — gas estimation failed or gas limit exceeded
INSUFFICIENT_FUNDS — wallet balance too low
SLIPPAGE — "Too little received" or similar DEX revert
TIMEOUT — transaction confirmation timed out
NONCE_ERROR — nonce mismatch or conflict
REVERT — generic transaction revert
RATE_LIMIT — RPC or API rate limit hit
NETWORK_ERROR — connection or network failure
COMPILATION_PERMANENT — unsupported protocol/chain (non-retriable)
None — unclassified error