Skip to content

Getting Started

This guide walks you through installing the Almanak SDK, scaffolding your first strategy, and running it locally on an Anvil fork -- no wallet or API keys required.

Prerequisites

  • Python 3.12+
  • uv (Python package manager):
curl -LsSf https://astral.sh/uv/install.sh | sh
  • Foundry (provides Anvil for local fork testing):
curl -L https://foundry.paradigm.xyz | bash
foundryup

Installation

pipx install almanak

Need the web dashboard or backtest charts/optimization? Install the extras: pipx install 'almanak[dashboard,backtest]'.

Or with uv:

uv tool install almanak

This installs the almanak CLI globally. Each scaffolded strategy also has almanak as a local dependency in its own .venv/ -- this two-install pattern is standard (same as CrewAI, Dagster, etc.).

Using an AI coding agent? Teach it the SDK in one command:

almanak agent install

This auto-detects your platform (Claude Code, Codex, Cursor, Copilot, and 6 more) and installs the strategy builder skill.

1. Get a Strategy

almanak strat demo

This shows an interactive menu of working demo strategies. Pick one and it gets copied into your current directory, ready to run. You can also skip the menu:

almanak strat demo --name uniswap_rsi

Option B: Scaffold from a template

almanak strat new

Follow the interactive prompts to pick a template, chain, and name. This creates a self-contained Python project with:

  • strategy.py - Your strategy implementation with decide() method
  • config.json - Runtime parameters (tokens, thresholds, funding)
  • pyproject.toml - Dependencies and [tool.almanak] metadata
  • uv.lock - Locked dependencies (created by uv sync)
  • .venv/ - Per-strategy virtual environment (created by uv sync)
  • .env - Environment variables (fill in your keys later)
  • .gitignore - Git ignore rules
  • .python-version - Python version pin (3.12)
  • __init__.py - Package exports
  • tests/ - Test scaffolding
  • AGENTS.md - AI agent guide

The scaffold runs uv sync automatically to install dependencies. To add extra packages later:

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

2. Run on a Local Anvil Fork

The fastest way to test your strategy -- no wallet keys, no real funds, no risk:

cd my_strategy
almanak strat run --network anvil --once

This command automatically:

  1. Starts an Anvil fork of the chain specified in your strategy (free public RPCs are used by default)
  2. Uses a default Anvil wallet -- no ALMANAK_PRIVATE_KEY needed
  3. Starts the gateway sidecar in the background
  4. Funds your wallet with tokens listed in anvil_funding (see below)
  5. Runs one iteration of your strategy's decide() method

Wallet Funding on Anvil

Add an anvil_funding block to your config.json to automatically fund your wallet when the fork starts:

{
    "chain": "arbitrum",
    "anvil_funding": {
        "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE": 10,
        "0xaf88d065e77c8cc2239327c5edb3a432268e5831": 10000,
        "0x82af49447d8a07e3bd95bd0d56f35241523fbab1": 5
    }
}

Every funding key is address-shaped. Use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE for the active chain's native asset; it is funded via anvil_setBalance. Other keys must be exact chain-specific ERC-20 contract addresses and are funded via the token funding pipeline. Bare symbols are rejected. This happens automatically each time the fork starts.

Better RPC Performance (Optional)

Free public RPCs work but are rate-limited. For faster forking, set an Alchemy key in your .env:

ALCHEMY_API_KEY=your_alchemy_key

This auto-constructs RPC URLs for all supported chains. Any provider works -- see Environment Variables for the full priority order.

3. Run on Mainnet

Warning

Mainnet execution uses real funds. Start with small amounts and use a dedicated wallet.

To run against live chains, you need a wallet private key in your .env:

# .env
ALMANAK_PRIVATE_KEY=0xYOUR_PRIVATE_KEY

# RPC access (pick one)
ALCHEMY_API_KEY=your_alchemy_key
# or: RPC_URL=https://your-rpc-provider.com/v1/your-key

Then run without the --network anvil flag:

almanak strat run --once

Tip

Test with --dry-run first to simulate without submitting transactions:

almanak strat run --dry-run --once

See Environment Variables for the full list of configuration options including protocol-specific API keys.

Before going live

  • Always run --dry-run --once before your first live execution to verify intent compilation without submitting transactions.
  • If swaps revert with "Too little received", switch from amount_usd= to amount= (token units). amount_usd= relies on the gateway price oracle for USD-to-token conversion, which may diverge from the DEX price.
  • Start with small amounts and monitor the first few iterations. The deployment ID is derived deterministically from your wallet and chain, so a restart resumes the same run automatically.

Strategy Structure

A strategy implements the decide() method, which receives a MarketSnapshot and returns an Intent:

from decimal import Decimal
from almanak import IntentStrategy, Intent, MarketSnapshot

class MyStrategy(IntentStrategy):
    def decide(self, market: MarketSnapshot) -> Intent | None:
        price = market.price("ETH")
        balance = market.balance("USDC")

        if price < Decimal("2000") and balance.balance_usd > Decimal("500"):
            return Intent.swap(
                from_token="USDC",
                to_token="ETH",
                amount_usd=Decimal("500"),
            )
        return Intent.hold(reason="No opportunity")

Strategy Metadata (@almanak_strategy)

The @almanak_strategy decorator attaches metadata used by the framework, CLI, and hosted platform:

@almanak_strategy(
    name="my_strategy",                  # Unique identifier
    description="What it does",          # Human-readable description
    version="1.0.0",
    tags=["trading", "rsi"],             # Optional tags for discovery
    supported_chains=["arbitrum"],
    supported_protocols=["uniswap_v3"],
    intent_types=["SWAP", "HOLD"],       # Every intent the strategy may return
    default_chain="arbitrum",
    quote_asset="USD",                   # Asset performance is measured in
)
class MyStrategy(IntentStrategy): ...

Quote asset (performance denomination)

quote_asset declares the asset your strategy's performance (PnL / ROI) 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 and the USD equity curve 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, and can show a loss in a falling-BTC market while actually accumulating BTC. Choose by asking what quantity the strategy is trying to grow — if the goal is stated ("increase BTC"), the denomination must match it. Declare it explicitly so the choice is visible.

  • USD: quote_asset="USD". Correct for LP strategies whose legs span asset families (e.g. WETH/USDC), USD/stable lending and vault yield, delta-neutral and basis trades, USD-collateral perps, and TA swaps that trade for USD profit.
  • Token: quote_asset={"type": "token", "chain_id": <int>, "address": "0x..."}. Only for strategies whose goal is to grow a quantity of that token — pure accumulators, native-asset or liquid staking, same-asset-family leverage loops (e.g. wstETH collateral / WETH borrow), same-asset-family LP pools built to grow that asset (e.g. a WBTC/tBTC pool as a BTC accumulator quotes in WBTC), and token-denominated yield (e.g. Pendle YT on wstETH). Use a numeric chain_id, never a chain name, and represent native gas tokens by their wrapped ERC-20 (ETH→WETH, MNT→WMNT; Solana uses chain_id: 0 with WSOL).

quote_asset is distinct from quote_token (a trading-pair leg in config). It can also be overridden per-deployment in config.json ("quote_asset": "USD" or the token object); the override is applied on live runs at boot (backtests read the decorator value) and is not hot-reloadable.

Available Intents

Intent Description
SwapIntent Token swaps on DEXs
HoldIntent No action, wait for next cycle
LPOpenIntent Open liquidity position
LPCloseIntent Close liquidity position
BorrowIntent Borrow from lending protocols
RepayIntent Repay borrowed assets
DeleverageIntent Emergency repay triggered by risk management. Factory: Intent.deleverage()
SupplyIntent Supply to lending protocols
WithdrawIntent Withdraw from lending protocols
StakeIntent Stake tokens
UnstakeIntent Unstake tokens
PerpOpenIntent Open perpetuals position
PerpCloseIntent Close perpetuals position
PerpWithdrawIntent Withdraw collateral from a perp venue (e.g., Hyperliquid account to L1)
PerpCancelIntent Cancel a pending perp order and recover committed collateral
FlashLoanIntent Flash loan operations (experimental — pending testing; not listed in almanak info matrix)
CollectFeesIntent Collect LP fees
PredictionBuyIntent Buy prediction market shares (experimental — pending testing; not listed in almanak info matrix)
PredictionSellIntent Sell prediction market shares (experimental — pending testing; not listed in almanak info matrix)
PredictionRedeemIntent Redeem prediction market winnings (experimental — pending testing; not listed in almanak info matrix)
VaultDepositIntent Deposit into a vault
VaultRedeemIntent Redeem from a vault
WrapNativeIntent Wrap native tokens (e.g., ETH to WETH). Factory: Intent.wrap()
UnwrapNativeIntent Unwrap native tokens (e.g., WETH to ETH). Factory: Intent.unwrap()
Intent.bridge() Bridge tokens cross-chain (factory method returning a composite intent)
Intent.ensure_balance() Ensure minimum token balance on a target chain (factory method resolving to a bridge or hold)
Intent.sequence() Atomic multi-step composite (IntentSequence) executing child intents in order with shared rollback semantics

State Persistence (Required for Stateful Strategies)

The framework automatically persists runner-level metadata (iteration counts, error counters) after each iteration. However, strategy-specific state -- position IDs, trade counts, phase tracking, cooldown timers -- is only saved if you implement two hooks:

from typing import Any
from decimal import Decimal

class MyStrategy(IntentStrategy):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._position_id: int | None = None
        self._trades_today: int = 0

    def get_persistent_state(self) -> dict[str, Any]:
        """Return state to save. Called after each iteration."""
        return {
            "position_id": self._position_id,
            "trades_today": self._trades_today,
        }

    def load_persistent_state(self, state: dict[str, Any]) -> None:
        """Restore state on startup. Called when resuming a run."""
        self._position_id = state.get("position_id")
        self._trades_today = state.get("trades_today", 0)

Without these hooks, your strategy will lose all internal state on restart. This is especially dangerous for LP strategies where losing the position_id means the strategy cannot close its own positions.

What gets lost without persistence

If you store state in instance variables (e.g., self._position_id) but don't implement get_persistent_state() and load_persistent_state(), that state is lost when the process stops. On restart, your strategy starts from scratch with no memory of open positions, completed trades, or internal phase.

Tips

  • Use defensive .get() with defaults in load_persistent_state() so older state dicts don't crash on missing keys.
  • Store Decimal values as strings (str(amount)) and parse them back (Decimal(state["amount"])) for safe JSON round-tripping.
  • 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.
  • Persist identity and phase (position IDs, cooldowns, workflow step) — not market exposure. Values that feed decide() triggers (debt, exposures, hedge deltas) should be re-read from the market snapshot each cycle: cached intent-derived amounts drift from on-chain reality as interest accrues and prices move.
  • Persisted cooldown/cadence timestamps must be market-clock values (market.timestamp), never wall-clock — see Time in Strategies.

Risk Baseline Lifecycle

Choose the baseline's lifetime to match the risk rule. For a loss limit measured from strategy inception, capture the initial measured equity before the first trade and persist it with get_persistent_state() / load_persistent_state(). Restore it on restart and preserve it when positions close or reopen. Resetting a $10,000 baseline to $9,000 after a $1,000 loss hides that loss from subsequent comparisons. Define how deposits, withdrawals, and an explicitly started new strategy lifecycle affect the baseline; do not silently reinitialize missing state when resuming an existing deployment.

A per-position stop instead establishes a new entry baseline after a confirmed open and retires it after the position closes. A trailing drawdown rule maintains a persisted high-water mark. Document which rule the strategy implements and when its reference value may change. These historical references are persistent state; current equity and exposure must still be measured each cycle. Compare the same assets, positions, and liabilities in the same units on both sides; wallet cash alone is not portfolio equity when capital is deployed in positions.

Time in Strategies

Every time-based rule in a strategy — cooldowns, trade cadences, daily counters, "N hours since last fill" — must be computed from market.timestamp, the snapshot's clock. Never call datetime.now() (or time.time()) inside decide() or on_intent_executed() for anything that feeds a decision.

Why: in live trading the two clocks agree, so wall-clock code appears to work. In a backtest, weeks of simulated time replay in minutes of wall time — a 24-hour cooldown measured against datetime.now() never expires, so the strategy trades once and holds forever. The bug is invisible in unit tests and single-iteration smoke runs; it only surfaces as a silently degenerate backtest.

on_intent_executed() receives no market snapshot, so capture the snapshot's timestamp in decide() and reuse the captured value when stamping state in the callback.

Creating a snapshot in a callback does not supply backtest time

Calling self.create_market_snapshot() inside a backtest callback can build a snapshot with the current wall-clock timestamp rather than the simulated trade timestamp. Do not use it to obtain historical time for a risk rule, cooldown, or holding period. Capture the timestamp from the snapshot passed to decide() instead.

The captured timestamp is decision time, not necessarily fill time. With delayed execution, a cooldown anchored to that timestamp starts before the fill. The example below implements a decision-time cadence recorded on success. A rule requiring exact time since fill needs a verified execution timestamp from the relevant execution surface; do not substitute decision time and label it fill time. If multiple intents can be pending, associate each captured timestamp with its own pending action rather than overwriting one shared timestamp.

class MyStrategy(IntentStrategy):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._last_buy_ts: datetime | None = None
        self._pending_ts: datetime | None = None

    def decide(self, market: MarketSnapshot) -> Intent:
        if self._pending_ts is not None:
            return Intent.hold(reason="Waiting for pending buy to finish")
        now = market.timestamp  # simulated time in backtests, real time live
        if self._last_buy_ts and now - self._last_buy_ts < timedelta(hours=24):
            return Intent.hold(reason="Cadence: waiting for next 24h window")
        self._pending_ts = now  # capture for the fill callback
        return Intent.swap(...)

    def on_intent_executed(self, intent, success, result) -> None:
        if success and self._pending_ts is not None:
            self._last_buy_ts = self._pending_ts  # market clock, NOT datetime.now()
        self._pending_ts = None

Wall-clock time is acceptable only for reporting fields that never feed a decision (e.g. the timestamp on a TeardownPositionSummary, log lines, dashboard "generated at" labels).

Unit tests can hide this bug

A test that monkeypatches a _now() helper on the strategy validates the internal logic while hiding the clock-domain defect. Drive time through the snapshot instead: build test snapshots with almanak.framework.market.testing.seeded(timestamp=...) and advance the timestamp between calls to decide() — see Unit Testing Strategies.

Strategy Teardown (Required)

Every strategy must implement teardown so operators can safely close positions. Without teardown, close-requests are silently ignored and positions remain open. The almanak strat new templates include stubs -- fill them in as you build your strategy.

class MyStrategy(IntentStrategy):
    def supports_teardown(self) -> bool:
        return True

    def get_open_positions(self) -> "TeardownPositionSummary":
        """Query on-chain state and return open positions."""
        from almanak.framework.teardown import PositionInfo, PositionType, TeardownPositionSummary
        # ... return TeardownPositionSummary with your positions

    def generate_teardown_intents(self, mode: "TeardownMode", market=None) -> list[Intent]:
        """Return ordered intents to unwind all positions."""
        from almanak.framework.teardown import TeardownMode
        max_slippage = Decimal("0.03") if mode == TeardownMode.HARD else Decimal("0.005")
        return [Intent.swap(
            from_token="WETH", to_token="USDC", amount="all",
            max_slippage=max_slippage, chain=self.chain,
        )]

When upgrading an existing strategy, update every teardown Intent.swap(...) to pass its configured execution chain explicitly. For a single-chain strategy, use chain=self.chain; for a multi-chain position, use the chain of that position. Apply the same change to custom teardown helper methods. Regenerating a template does not update previously created strategy files. Persisted pending swaps without a chain are rejected with a diagnostic; rebuild the teardown plan from the strategy's positions after updating the producer. The runner does not infer or backfill a missing chain.

If your strategy holds multiple position types, close them in order: perps -> borrows -> supplies -> LPs -> tokens. See the Teardown CLI for how operators trigger teardown.

Two contract points that are easy to get wrong:

  • Teardown is one-shot. The list returned by generate_teardown_intents() is the entire unwind plan — the framework executes it and stops. There are no follow-up decide() iterations, and no "next pass" that continues a partial unwind. A strategy that returns only its LP close and assumes the borrow/supply legs get repaid "on a subsequent iteration" strands live debt and collateral. If exact downstream amounts aren't knowable up front (e.g. how much WETH an LP close returns), use amount="all" / repay_full=True style intents rather than deferring legs to an iteration that will never run. For leveraged positions, almanak.framework.teardown.generate_lending_unwind(...) builds the sanctioned repay-and-withdraw sequence — compose it: [lp_close_intent, *generate_lending_unwind(...)].
  • Report every leg in get_open_positions(). A leveraged LP strategy holds three positions — the LP (PositionType.LP), the debt (PositionType.BORROW), and the collateral (PositionType.SUPPLY) — and all of them belong in the TeardownPositionSummary. The framework's teardown-completeness verification compares closed positions against this summary; omitting the lending legs blinds that check, and a half-unwound position gets reported as cleanly COMPLETED.

Backtesting Exact Pools and Perp Markets

If your config already names the pool (pool or swap_pool) or the perp market (market and market_address), the backtester uses that as a hint to load the venue's history before the first tick. The hint is optional and can never fail a run: anything it misses is resolved the first time an intent or a read names it.

Write your strategy for live execution and backtest the same code. The backtester learns which pool or perp market you trade from the intents you emit — the pool on Intent.lp_open() and the market on Intent.perp_open() — not from config keys, so name your config fields however you like.

  • An exact pool address is authenticated from archive state the first time an LP intent names it (token pair, fee tier, and the factory round-trip). If the archive cannot prove the pool, the intent is rejected with POOL_METADATA_UNAVAILABLE; nothing silently falls back to another pool. Symbolic pools ("WETH/USDC/500") work as well.
  • A GMX market address is resolved through the venue catalogue and its candle and funding history is loaded the first time a perp intent names it.
  • market.twap(...) and pool-analytics reads that name an exact pool fetch its history inline on first use, like a live RPC.

Declaring targets up front is optional. Implement get_backtest_pool_state_targets() (or set a backtest_pool_state_targets attribute) and backtest_perp_price_history_targets() if you want the readiness check to verify them before the run starts.

Unit Testing Strategies

Build test snapshots with the real MarketSnapshot seeding API — not unittest.mock.MagicMock:

from decimal import Decimal
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace

from almanak.framework.market.testing import seeded

def test_cadence_gates_second_buy(strategy):     # `strategy` = your fixture
    t0 = datetime(2026, 1, 1, tzinfo=UTC)
    market = seeded(
        chain="base",
        prices={"WETH": Decimal("2000")},
        timestamp=t0,
    )
    intent = strategy.decide(market)             # first buy fires
    assert intent.intent_type.value == "SWAP"
    result = SimpleNamespace(swap_amounts=None)  # minimal execution result
    strategy.on_intent_executed(intent, True, result)

    later = seeded(chain="base", prices={"WETH": Decimal("2000")}, timestamp=t0 + timedelta(hours=1))
    assert strategy.decide(later).intent_type.value == "HOLD"   # cadence gate holds

seeded(...) accepts prices, balances (real TokenBalance objects), indicators (real RSIData / MACDData / BollingerBandsData, keyed "TOKEN:indicator:period:timeframe"), and a timestamp. Individual seed_price(...) / seed_balance(...) / seed_rsi(...) calls on the snapshot cover the rest.

Why not MagicMock? A mocked market answers any method call — a misspelled accessor, a wrong keyword argument, or an API that doesn't exist all pass green, so the tests validate nothing about your integration with the SDK. And mocking time helpers on the strategy (instead of driving timestamp through the snapshot) hides wall-clock bugs that break backtests — see Time in Strategies. Reserve MagicMock for execution-result objects in on_intent_executed() tests, where no seeding API exists.

Generating Permissions (Safe Wallets)

When deploying a strategy through a Safe wallet with Zodiac Roles restrictions, the agent needs an explicit set of contract permissions. The SDK can generate this manifest automatically by inspecting which contracts and function selectors your strategy's intents compile to:

# From your strategy directory
almanak strat permissions

# Explicit directory
almanak strat permissions -d almanak/demo_strategies/uniswap_rsi

# Override chain
almanak strat permissions --chain base

# Write to file
almanak strat permissions -o permissions.json

The command reads supported_protocols and intent_types from your @almanak_strategy decorator, compiles synthetic intents through the real compiler, and extracts the minimum set of contract addresses and function selectors needed. The output is a JSON manifest you can apply to a Zodiac Roles module. If the strategy supports multiple chains, the output is a JSON array with one manifest per chain; use --chain to generate for a single chain.

Only for Safe/Zodiac deployments

Permission manifests are only needed when running through a Safe wallet with Zodiac Roles. For local Anvil testing or direct-key execution, no permissions are required.

Backtest CLI

Unlike almanak strat run which auto-discovers the strategy from the current directory, backtest commands require an explicit strategy name: almanak strat backtest pnl -s my_strategy. Use --list-strategies to see available strategies.

Next Steps

Want an LLM to Make the Decisions?

The SDK also supports agentic strategies where an LLM autonomously decides what to do using Almanak's 41 built-in tools. Instead of writing decide() logic in Python, you write a system prompt and let the LLM reason over market data.

This approach requires your own LLM API key (OpenAI, Anthropic, or any OpenAI-compatible provider).

Deterministic (this guide) Agentic
You write Python decide() method System prompt + policy
Decision maker Your code LLM (GPT-4, Claude, etc.)
Requires Just the SDK SDK + LLM API key
Best for Known rules, quantitative signals Complex reasoning, multi-step plans

Both paths share the same gateway, connectors, and execution pipeline.

Get started: Agentic Trading Guide

Explicit chains on teardown swaps

Every swap returned by generate_teardown_intents() must specify chain. Single-chain strategies use chain=self.chain; multi-chain strategies use the chain of the position or inventory they are closing. Teardown validates this before reading balances and never infers a missing chain. Invalid chains fail the affected intent while independent valid exits continue.

Intent.swap(from_token=self.base_token, to_token=self.quote_token,
            amount="all", chain=self.chain, max_slippage=self.max_slippage)

The swap remains limited to the lesser of strategy-tracked inventory and live balance on that chain. Supplying a chain does not authorize sweeping unrelated wallet holdings. Unmeasured inventory is refused, including historical rows that lack the chain identity needed to prove ownership.

Configuring TA swap exits

For ta_swap, normal teardown uses the same max_slippage_bps as ordinary swaps. Emergency teardown uses hard_teardown_max_slippage_bps, explicitly shown in the generated config. Defaults retain the prior tolerances: 50 bps normal and 300 bps emergency. Both must be finite, nonnegative and below 10,000 bps.

Optional protocol and swap_params settings apply to entries and exits alike. For example, a Uniswap V3 route may pin its pool with "protocol": "uniswap_v3" and "swap_params": {"pool": "0x…"}. Leaving them null preserves automatic routing. Teardown always supplies the explicit chain.

Basis-trade collateral and leverage

The basis_trade scaffold exposes perp_leverage in config.json. Its default remains 10× and must be reviewed before running. A spot hedge does not prevent liquidation of an undercollateralized perp leg: spot and perp margin are separate. The template validates leverage against the venue's declared capability range.

Perp notional is spot_size_usd * hedge_ratio; collateral value is notional divided by perp_leverage. The strategy converts that value into quote-token units using a measured price and checks funds for both the spot purchase and remaining perp collateral before entry. Missing/invalid prices prevent entry rather than assuming a dollar peg. Funding and execution fees are separate from this collateral budget; the existing compiler/execution fee and gas preflights still apply to each order. Maintain native funds for the full lifecycle, including close orders. This config value is a leverage setting, not a fee estimate or an assurance against liquidation.