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
.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
Intent.perp_close(
market="ETH/USD",
collateral_token="USDC",
is_long=True,
size_usd=None, # None = close full position
max_slippage=Decimal("0.01"),
protocol="gmx_v2",
position_id=None, # Required for venues keyed on bytes32 (e.g. pancakeswap_perps)
)
Intent.perp_withdraw - Withdraw free margin off a perp venue's off-chain account back to L1 (a cash movement, not a trade — no position, no PnL). On Hyperliquid this compiles to a CoreWriter perp→spot usdClassTransfer followed by a spot→L1 spotSend HyperCore→HyperEVM bridge of USDC (VIB-5617).
Intent.perp_withdraw(
amount=Decimal("6.99"), # human token amount, or "all" ONLY as a chained amount (a prior step's output)
asset="USDC", # the only HyperCore bridge-linked token today
protocol="hyperliquid",
chain="hyperevm",
destination=None, # defaults to the deployment wallet; the bridge always credits the sender
)
Intent.perp_cancel_order - Cancel a pending (unfilled) perp order and recover its committed collateral and unspent execution fee (VIB-5568). Not a position open/close — a refund of committed-but-unspent collateral, e.g. to recover a stranded pending order discovered during teardown.
Intent.perp_cancel_order(
order_key="0x...", # bytes32 order key (0x-prefixed, 66 chars) from the open receipt or residual discovery
protocol="gmx_v2", # Default: gmx_v2
chain=None, # Optional override
)
Bridging
Intent.bridge - Cross-chain token transfer
Intent.bridge(
token="USDC",
amount=Decimal("1000"),
from_chain="arbitrum",
to_chain="base",
max_slippage=Decimal("0.005"),
preferred_bridge=None, # Optional: specific bridge protocol
)
Staking
Intent.stake - Liquid staking deposit
Intent.stake(
protocol="lido",
token_in="ETH",
amount=Decimal("10"),
receive_wrapped=True, # Receive wrapped token (e.g., wstETH)
)
Intent.unstake - Withdraw from liquid staking
Intent.unstake(
protocol="lido",
token_in="wstETH",
amount=Decimal("10"),
protocol_params=None, # Optional: e.g. {"phase": "cooldown"} for Ethena
)
Flash Loans
Intent.flash_loan - Borrow and repay in a single transaction
Intent.flash_loan(
provider="aave", # "aave", "balancer", "morpho", or "auto"
token="USDC",
amount=Decimal("100000"),
callback_intents=[...], # Intents to execute with the borrowed funds
)
Vaults (ERC-4626)
Intent.vault_deposit - Deposit into an ERC-4626 vault
Intent.vault_deposit(
protocol="metamorpho", # Vault protocol
vault_address="0x...", # Vault contract address
amount=Decimal("1000"), # Amount of underlying to deposit (or "all")
deposit_token="USDC", # Underlying token symbol (for backtesting)
chain="ethereum", # Optional: override chain
)
Intent.vault_redeem - Redeem shares from an ERC-4626 vault
Intent.vault_redeem(
protocol="metamorpho", # Vault protocol
vault_address="0x...", # Vault contract address
shares=Decimal("1000"), # Shares to redeem (or "all")
deposit_token="USDC", # Underlying token symbol (for backtesting)
chain="ethereum", # Optional: override chain
)
Prediction Markets
Intent.prediction_buy(
market_id="will-bitcoin-exceed-100000", # Polymarket market ID or slug
outcome="YES", # "YES" or "NO"
amount_usd=Decimal("100"), # USDC to spend (or use shares=)
protocol="polymarket",
)
Intent.prediction_sell(
market_id="will-bitcoin-exceed-100000",
outcome="YES",
shares=Decimal("50"), # Shares to sell (or "all")
protocol="polymarket",
)
Intent.prediction_redeem(
market_id="will-bitcoin-exceed-100000", # Redeem after market resolves
protocol="polymarket",
)
Cross-Chain
Intent.ensure_balance - Meta-intent that resolves to a BridgeIntent (if balance is insufficient) or HoldIntent (if already met). Call .resolve(market) before returning from decide().
intent = Intent.ensure_balance(
token="USDC",
min_amount=Decimal("1000"),
target_chain="arbitrum",
max_slippage=Decimal("0.005"),
preferred_bridge=None,
)
# Must resolve before returning - returns BridgeIntent or HoldIntent
resolved = intent.resolve(market)
return resolved
Token Utilities
Intent.wrap (WrapNative) - Wrap native tokens to ERC-20 (ETH -> WETH, MATIC -> WMATIC, etc.)
Intent.wrap(
token="WETH", # Wrapped token symbol to receive
amount=Decimal("0.5"), # Amount of native token to wrap (or "all")
chain="arbitrum", # Target chain
)
Intent.unwrap (UnwrapNative) - Unwrap wrapped native tokens (WETH -> ETH, WMATIC -> MATIC, etc.)
Intent.unwrap(
token="WETH", # Wrapped token symbol
amount=Decimal("0.5"), # Amount to unwrap (or "all")
chain="arbitrum", # Target chain
)
Control Flow
Intent.hold - Do nothing this iteration
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"), # Uses swap output
])
Market Data API
The MarketSnapshot passed to decide() provides these methods:
Prices
price = market.price("WETH") # Decimal, USD price
price = market.price("WETH", quote="USDC") # Price in USDC terms
pd = market.price_data("WETH") # PriceData object
pd.price # Decimal - current price
pd.price_24h_ago # Decimal
pd.change_24h_pct # Decimal
pd.high_24h # Decimal
pd.low_24h # Decimal
pd.timestamp # datetime
For a non-crypto reference such as XAU/USD, use the dedicated exact-feed API. Never substitute
market.price("XAU"): generic token pricing does not carry the required feed identity or market
session state.
reference = market.reference_price("XAU", chain="bsc", quote="USD")
reason = reference.trade_block_reason(max_age_seconds=300, min_confidence=0.90)
if reason is not None:
return Intent.hold(reason=reason)
reference.price # Decimal | None
reference.source # exact provider/feed identity
reference.observed_at # provider observation time, not gateway receipt time
reference.market_status # OPEN | CLOSED | UNKNOWN
reference.market_status_as_of # time at which the gateway evaluated the session
reference.stale # provider heartbeat result
The API returns a typed unavailable result rather than an inferred value. is_tradeable(...) and
trade_block_reason(...) fail closed for unavailable, malformed, stale, closed/unknown-session,
future-dated, over-age, or low-confidence observations.
Balances
bal = market.balance("USDC")
bal.balance # Decimal - token amount
bal.balance_usd # Decimal - USD value
bal.symbol # str
bal.address # str - token contract address
TokenBalance supports numeric comparisons: bal > Decimal("100").
Technical Indicators
All indicators accept token, period (int), and timeframe (str, default "4h").
rsi = market.rsi("WETH", period=14, timeframe="4h")
rsi.value # Decimal (0-100)
rsi.is_oversold # bool (value < 30)
rsi.is_overbought # bool (value > 70)
rsi.signal # "BUY" | "SELL" | "HOLD"
macd = market.macd("WETH", fast_period=12, slow_period=26, signal_period=9)
macd.macd_line # Decimal
macd.signal_line # Decimal
macd.histogram # Decimal
macd.is_bullish_crossover # bool
macd.is_bearish_crossover # bool
bb = market.bollinger_bands("WETH", period=20, std_dev=2.0)
bb.upper_band # Decimal
bb.middle_band # Decimal
bb.lower_band # Decimal
bb.bandwidth # Decimal
bb.percent_b # Decimal (0.0 = at lower band, 1.0 = at upper band)
bb.is_squeeze # bool
stoch = market.stochastic("WETH", k_period=14, d_period=3)
stoch.k_value # Decimal
stoch.d_value # Decimal
stoch.is_oversold # bool
stoch.is_overbought # bool
atr_val = market.atr("WETH", period=14)
atr_val.value # Decimal (absolute)
atr_val.value_percent # Decimal, percentage points (2.62 means 2.62%, not 0.0262)
atr_val.is_high_volatility # bool
sma = market.sma("WETH", period=20)
ema = market.ema("WETH", period=12)
# Both return MAData with: .value, .is_price_above, .is_price_below, .signal
adx = market.adx("WETH", period=14)
adx.value # Decimal
adx.plus_di # Decimal
adx.minus_di # Decimal
adx.is_trending # bool
adx.is_uptrend # bool
obv = market.obv("WETH", signal_period=21)
obv.value # Decimal
obv.signal # Decimal
obv.is_bullish # bool
cci = market.cci("WETH", period=20)
cci.value # Decimal
cci.is_overbought # bool
cci.is_oversold # bool
ich = market.ichimoku("WETH", tenkan_period=9, kijun_period=26, senkou_b_period=52)
ich.tenkan_sen # Decimal (conversion line)
ich.kijun_sen # Decimal (base line)
ich.senkou_span_a # Decimal (leading span A)
ich.senkou_span_b # Decimal (leading span B)
ich.is_bullish_crossover # bool
ich.is_above_cloud # bool
Multi-Token Queries
# Per-token reads work today on every deployment surface.
weth_price = market.price("WETH") # Decimal
wbtc_price = market.price("WBTC") # Decimal
usdc_bal = market.balance("USDC") # TokenBalance
weth_bal = market.balance("WETH") # TokenBalance
usd_val = market.balance_usd("WETH") # Decimal - USD value of holdings
total = market.total_portfolio_usd() # Decimal
Batch helpers are Phase 2. The deprecated data-layer class exposes
market.prices([...])/market.balances([...])batch fetchers. The canonical strategy-facing class deliberately does NOT lift these names in the ALM-2696 fix because legacy callers (runner_state.py, trust tests) historically usedhasattr(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.
# USD value of an arbitrary collateral amount (for perp position sizing)
col_usd = market.collateral_value_usd("WETH", Decimal("2")) # Decimal - amount * price
OHLCV Data
df = market.ohlcv("WETH", timeframe="1h", limit=100) # pd.DataFrame
# Columns: open, high, low, close, volume
Pool and DEX Data
Use these (not
market.price()) for anything execution-facing — LP range bounds, range-exit tests.market.price()is a USD valuation oracle (hardcoded1.0for stablecoins) and can silently diverge from the pool's actual price. See "LP Rebalancing" above.
pool = market.pool_price("0x...") # DataEnvelope[PoolPrice]
pool = market.pool_price_by_pair("WETH", "USDC") # DataEnvelope[PoolPrice]
reserves = market.pool_reserves("0x...") # PoolReserves
history = market.pool_history("0x...", resolution="1h", protocol="uniswap_v3") # DataEnvelope[list[PoolSnapshot]]
# `protocol` is REQUIRED keyword-only (VIB-4755 D-2 — closes silent cross-protocol surface).
# Must match the pool's actual protocol slug: "uniswap_v3", "aerodrome", "pancakeswap_v3", etc.
# A defaulted protocol on a non-uniswap_v3 pool address would have routed through
# CoinGecko Onchain (which does not filter on protocol slug) and silently labelled the
# served data with the wrong protocol — see docs/internal/uat-cards/VIB-4755.md §D-2.
analytics = market.pool_analytics("0x...") # DataEnvelope[PoolAnalytics]
best = market.best_pool("WETH", "USDC", metric="fee_apr") # DataEnvelope[PoolAnalyticsResult]
Provider availability:
pool_*,twap/lwap,liquidity_depth,estimate_slippage,pool_analytics/best_pool,il_exposure/projected_il,realized_vol/vol_cone,portfolio_risk/rolling_sharpe,yield_opportunities,lst_*, prediction-market methods, and the rate-history methods are all provider-driven. The runner wires the corresponding provider (pool reader registry, price aggregator, IL calculator, …); when a provider is not wired the method raisesValueError("No <X> configured for MarketSnapshot")rather than returning silently. Do not guard these calls withhasattr(market, ...)— the methods always exist; catchValueError(or one of the typed*UnavailableErrorsubclasses defined inalmanak.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 raiseValueErrorwhen no provider is wired),prediction_price()returnsNoneas a soft-signal fallback. Strategies that use it as a side-channel signal can therefore branch onif (p := market.prediction_price(...)) is not None:instead of wrapping the call intry / 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) # DataEnvelope[AggregatedPrice]
# Explicit-pool form: when you pass `pool_address` directly, you must also
# pass token decimals (or have a `pool_reader_registry` wired so the snapshot
# can resolve them automatically). There is no "WETH/USDC default" — the
# decimals are required for the tick-to-price conversion.
twap = market.twap(
"WBTC/WETH",
pool_address="0x...",
token0_decimals=8, token1_decimals=18,
)
lwap = market.lwap("WETH/USDC") # DataEnvelope[AggregatedPrice]
depth = market.liquidity_depth("0x...") # DataEnvelope[LiquidityDepth]
slip = market.estimate_slippage("WETH", "USDC", Decimal("10000")) # DataEnvelope[SlippageEstimate]
# Canonical fields live on slip.value and are integer basis points:
if not slip.value.within_limits(max_slippage_bps=75, max_price_impact_bps=100):
return Intent.hold(reason="pre-trade slippage or price impact exceeds limit")
prices = market.price_across_dexs("WETH", "USDC", Decimal("1")) # list[DexQuote]
best_dex = market.best_dex_price("WETH", "USDC", Decimal("1")) # BestDexResult
For an approved-pool strategy, pin every execution-facing read to the same address. A useful USD depth contract is to simulate a trade equal to the configured minimum depth and require its effective slippage to remain inside the limit. Check both directions when both entry and exit are possible; convert the base-side USD amount with the already-validated reference price.
pool = market.pool_price(APPROVED_POOL, chain="bsc")
depth = market.liquidity_depth(APPRO
…(truncated)