Skip to content

Operating retained workspaces

Execution requires run --fork or run --production with an explicit operator file (--gateway-config) and the private provider env file (--env-file). Credentials never select production implicitly. Each private workspace captures the authored inputs, source, the generated read context, resolved facts, setup stages, endpoint references, process identities and logs. state.sqlite contains authoritative execution facts; workspace.json contains supervision. Keep credentials outside strategy-readable paths.

Operator configuration

The operator file is YAML or JSON with schema_version: operator-gateway-config-v1. It selects the enabled modes, one provider per chain under networks (alchemy with api_key_env, or rpc_url with url_env; the env file holds the values), named vendor access under vendors when a backtest requests one, the named accounts.bindings the author file requires, and mode authority: fork holds the fixture anchors, funds and operators; production holds execution networks, independent payer ceilings and signing file references. When the author requests gas policy, fork.gas also supplies explicit operator ceilings, grouped by the fixture's actual payer relationships. A shared operator requires one compatible policy and ceiling for all accounts it submits for. The fork, production and backtest examples cover the Aave reference. An operator file never carries provider keys.

A run first binds the authored choices to the operator file and writes the read context, then starts a read-only gateway that resolves the named markets at the selected anchors and retains the verified facts. Fork runs read the anchors from the operator fixture; production runs pin fresh anchors. Hooks run once on the resolved parameters and the results are frozen before any execution authority exists. check --online performs the same resolution and hook stages in a temporary directory and reports a preview of the resolved facts without creating a workspace.

Command Effect
status --run PATH Retained financial state and current local process availability.
pause --run PATH Wait for coordinator pause; leave services and positions available.
resume --run PATH Explicit recovery through captured inputs and retained execution.
teardown --run PATH Persist closure intent and start valid cleanup.
shutdown --run PATH Pause then stop services after closure.
shutdown --run PATH --force Stop despite unresolved obligations and preserve evidence.
report --run PATH --format html --output FILE Offline shared report and optional custom hook.

resume returns once the supervisor accepts it. Admission, the gateway start and the coordinator start then run in the background: status reports provisioning: true until they finish and then the resulting stage, such as running, action_required or failed with its diagnostic. Other commands except pause are refused meanwhile.

Production admission reports every gap in one diagnostic. When funding is missing and Safe permissions are not installed, the stage is action_required with both reasons, and the owner transactions are already exported under private/deployment/permissions/.

Ctrl-C on a foreground run requests pause and waits for acknowledgement. Pending work and open positions remain recorded. A failed coordinator is not restarted automatically. Its diagnostic is the refusal the coordinator reported, such as an inventory shortfall or a gas_limit or gas_budget refusal naming the requested amount, the estimate or quote and the configured limit, and status keeps reporting it after the supervisor exits. A coordinator that stopped without a reported refusal leaves the generic instruction to inspect its log. A recorded transaction revert (execution_failed) or accounting issue (accounting) blocks new exposure on every resume, so that stop is the failure teardown_required and its next command is almanak teardown --run PATH: teardown mode passes the block, observes again and plans the closes afresh. A market fee quote above max_gas_price_wei (gas_price) or max_transaction_fee (gas_fee_quote) does not stop a single-chain coordinator: it signs nothing, keeps its decision and quotes again with a back-off of up to a minute, for as long as the market stays above the ceiling. While it holds, status stays running and the diagnostic names the kind and the policy field; the coordinator log has a gas_ceiling_hold line per attempt with the quote, then gas_ceiling_recovered once a plan is admitted or gas_ceiling_dropped when the decision is dropped. On a multi-chain deployment the refusal is the chain's retained fault and the chain is retried on each pass. almanak test run stops on such a hold at once. Explicit resume preserves pending identities and performs the existing lease, signer-claim and receipt audits. Never remove a journal to clear a pending transaction.

A strategy that declares price series needs no additional service or credential: the gateway reads each series from the chain reader of the series' chain, which the deployment must route. Provisioning refuses a series on a chain the deployment has no provider for, or whose connector has no reviewed deployment there; at launch the gateway resolves each declared pool and refuses to serve one that does not resolve or does not hold the declared asset. Before each decision the gateway reads the series as of the earlier of the decision time and the observed block time, and returns only the series the frozen preparation declared. Boundary blocks, points and their evidence are retained in strategy-prices.sqlite beside the journal, so a decision reads only boundaries it has not seen, and each served series, holds included, is appended there. A fork reads history before its fork block through its node, which forwards those reads to the upstream provider. Single strategy prices are a backtest-only Aave V4 input and are not served. A failed read at a decision records a hold for that decision; teardown decisions read no series.

The gateway holds the journal's write lock only to commit a request. Provider reads and remote signing run against the checkpoint read under the current lease; the commit re-checks the lease and refuses a recording whose checkpoint changed meanwhile, and a broadcast re-checks its envelope, hash and nonce and sends only under the lock. Renewals therefore never wait behind a slow plan or signature. Replacement families keep the lock for their whole request. Each gateway_request log line reports provider calls by method (rpc_calls, rpc), HTTP exchanges with the provider (rpc_round_trips, a batch counting once), provider, signer and lock time (rpc_ms, signer_ms, lock_wait_ms, lock_held_ms) alongside duration_ms.

Fork fixtures fund each declared asset with simulated capital. The chain's wrapped native asset is funded by wrapping and transfer, and nothing else is tried. Every other ERC-20 goes through an ordered ladder: a reviewed token-specific funder, a transfer from a listed holder, a known balance slot or seed recipe, anvil_dealERC20, then a probe of common balance slots unless the token has a seed recipe. A token with a reviewed funder or a listed holder never falls through to the generic routes; a holder short of the amount at the fork head (after any earlier fixture transfers) is refused. Funding refuses a token address with no contract code. Each ladder route must leave the exact balance and pass a transfer check: the account sends part of it to an address derived from the account and token, which must start empty and receive exactly that amount, and the fork reverts the check. The storage routes (known_slot, seed_recipe, deal_erc20, probed_slot) write the balance without minting, so totalSupply is left unchanged; such a route is refused when totalSupply is below the funded amount. A failed route is rolled back before the next one runs. When no route passes, the fixture refuses and names each outcome; nothing is partially funded. If anvil_dealERC20 is still running after 20 s, the node keeps tracing in the background, so funding stops without rolling back and the fork must be recreated. In the compiled fixture plan every non-wrapped ERC-20 has method: local_storage, which means "any ERC-20 route", not a storage write. The simulated_initial_capital rows in fixture-installation.json record the route actually used in method (wrap_native, reviewed, holder_transfer, known_slot, seed_recipe, deal_erc20 or probed_slot) with its slot, seed or holder, so a fork pass that relied on unreviewed funding is visible.

Fork resume verifies the original process and Anvil instance, chain and origin. A stopped fork cannot be recreated as the same deployment. Interrupted fixture funding reverts only through the retained snapshot token of the original node; an uncertain rollback, a lost node, or execution state without its preparation is refused. A completed fixture is reused and never rolled back. Production provisioning writes reviewable artifacts and performs no owner actions. Multi-host supervision is unsupported.

Fork tests

almanak test config writes a fork operator file from the author configuration, with the shape of the shipped examples/operators/*.fork.yaml:

uv run --locked almanak test config ./my-strategy --env-file /private/providers.env \
  --output /private/operator/my-strategy.fork.yaml [--block N] [--force]

It names one Alchemy provider per authored chain through ALCHEMY_API_KEY, placeholder account bindings, anchors at the latest block (or --block, for a single-chain strategy; a chain with a GMX market anchors at least three hours behind the head, so the authentic keeper reports a fork test replays exist for its requests), Safe/Roles fixture funds sized from an online fork check's funding block plus 1%, one fixture operator per chain and gas policy holding at least one native unit and twice its entry minimum plus gas budget, and fork.gas ceilings equal to the authored strategy.gas. On Base and Optimism, where forks cannot bound the L1 data and operator fees and authored ceilings are refused, the file sets fork.author_gas: unenforced and carries a note: the fork runs gasless and the authored policy is retained but not enforced. An operator file without that choice keeps the refusal. An existing --output is replaced only with --force.

almanak test run runs one supervised fork lifecycle through the same workspace run --fork creates:

uv run --locked almanak test run ./my-strategy \
  --gateway-config /private/operator/my-strategy.fork.yaml \
  --env-file /private/providers.env --output /private/runs/test-1 \
  [--decisions 3] [--advance SECONDS] [--through-exit] [--param KEY=VALUE ...] \
  [--production-parameters] [--json]

It waits for admission, then lets up to --decisions decisions settle. Between them it advances every fork node's time by the decision interval (or --advance, raised to the next due decision when shorter, so it never waits on the wall clock) with evm_setNextBlockTimestamp and evm_mine, and moves the coordinator's decision clock by the same offset; the author's interval is never edited. It stops early once an intent that increases exposure has executed, unless --through-exit keeps it deciding up to --decisions, so the strategy's own later intents run: the mint after an LP entry swap, fee collection, a recentre, its own close or sell. Size --advance so those fall within the decisions, e.g. past a hold time. After the entry, --idle consecutive holds (default 3) end the decisions early: a short run of holds is a strategy waiting on its own timer, a longer one means it has nothing left to do. It then requests teardown, waits until the deployment is closed, shuts the services down and releases the run's fund claims. Interrupts and failures still stop every recorded service.

The config's strategy.test_parameters replace their declared strategy.parameters values in the workspace's copy of the inputs before admission, unless --production-parameters is given. Each --param KEY=VALUE (repeatable; split at the first =, the value read as one YAML scalar, so 1 is an integer, true a boolean and '"0.5"' a string) replaces one on top of them. Each takes the declared type: a string knob keeps the text as written (1.10 stays '1.10'), a float knob takes any finite number (0.00001, 1e-5), an integer refuses _ and exponents, and null is refused unless config.yaml declares null. With a typed model the model converts it instead. An undeclared key refuses the run as an input error. config.yaml is never modified.

Teardown never admits an intent that increases exposure; the coordinator drops it and logs exposure_refused_in_teardown. A strategy that proposes one 3 times with no teardown outcome between them ends the wait as teardown_refused (class strategy), with the intent kind and allocation in its details.

While teardown waits, a fork whose journal has stalled for 30 seconds has its time advanced, except while a decision is retained without a plan, attempt or an unresolved request of its own (a completed GMX request exempts nothing): the gateway prepares that decision's plan again against the chain before it reserves it, and a block mined in between would refuse it as a plan that differs from verified preparation.

A fork runs no protocol keeper, so a GMX order, deposit or withdrawal would stay pending. While the run waits on an accepted GMX request it settles it the way a keeper would, with nothing synthesized: it reads a keeper execution mined on the origin chain after the request was created, impersonates that keeper (which already holds the role at the fork origin) and submits its unchanged signed Chainlink Data Streams payloads to the fork's handler, at the reports' time. GMX's contracts then verify the signatures, report times, acceptable price and triggers. A request with no eligible report within its expiration stops the lifecycle with that reason; the source search is retained under keepers/ in the workspace.

Keepers publish when somebody's request is pending, so on a quiet market they can be minutes apart, longer than GMX's 300-second request expiration. Because the fork runs behind the chain head, the run knows the coming publications and times the fork by them: test config starts a single-market GMX fork where usable publications stay under four minutes apart for fifteen minutes after admission, and each later advance (the --advance step, the start of teardown and its stall advances) is lengthened when needed so the decision lands 120 to 280 seconds before a usable publication: its request follows the decision's approvals, and its reports must come after the request and within GMX's 300-second expiration of it. This only chooses when the fork acts; the strategy's interval and every report are unchanged. The verdict is printed and retained as test-verdict.json in the new workspace; an existing --output is refused and keeps its earlier verdict:

Field Meaning
schema almanak-test-verdict-v1
workspace The fork workspace directory, or null when the inputs were refused
parameter_overrides The applied parameter values by key, in the declared type, or with a typed model as written for the model to convert: strategy.test_parameters (unless --production-parameters) with any --param on top; {} when none applied or the inputs were refused
parameter_sources Per key in parameter_overrides, where its value came from: test_parameters or --param
admission ok, and the refusal (an input error document, or the failed stage and its diagnostic, with the supervisor's failure kind as error when it recorded one), classified like a failure
decisions Per decision: time, result (hold or intent) and the intents kinds; settled: false marks a decision the lifecycle stopped on before its work settled. From the decision_evaluated line the test run's coordinator logs (only almanak test run opts its coordinator in; production and hosted deployments never log it): evaluated (false when an unavailable price series held the decision before the strategy ran, with price_error), observed allocations, price_series (per declared series: asset, complete, points, window, latest_at, age_seconds; pool-sourced series also carry timeframe, gaps and max_age_seconds) and availability (chains not ready, with their fault). On a multi-chain deployment chain names the chain whose tick evaluated the decision; a row is given facts only when they are unambiguously its own. observation_errors lists each *_error field an observation recorded instead of a measurement, as allocation, field (its dotted path in the observation) and message; an entry_quote_error there means the strategy saw no entry to take, for example on an empty pool. These are facts, not the strategy's reason for a hold
transactions Per executed transaction: trigger (decision, or teardown for teardown's own transactions), action, allocation, status, gas_used, via_roles, transaction
entered, closed Whether an entry executed and the deployment closed. A request the connector settled unsuccessfully, such as a GMX order its keeper cancelled, is not an entry
evidence_clean The final journal holds no pending or reserved attempt, undelivered outcome, accounting issue, integrity error or chain fault
executed Intent kinds that executed completely and successfully, in first-execution order; teardown's are not included, nor an intent whose request was cancelled
after_entry The executed kinds that ran after the first entry: what --through-exit exercised beyond it
teardown_executed Intent kinds teardown executed completely, in first-execution order: an exit the strategy never ran itself was still exercised when it is listed here. Each kind is listed once; teardown_sequence has every execution
teardown_sequence Every intent teardown executed completely, in order and with repeats, as intent and allocation: two exit swaps around a withdrawal are two rows
teardown_decisions Every teardown decision the coordinator evaluated, in order: time, result (hold or intent), the intents kind and the same facts as decisions rows (evaluated, observed, observation_errors, availability). In teardown a hold claims that nothing is left to do; when the last row holds, closure is closed, or not_closed with the teardown failure's kind as refusal (such as remaining_obligations, whose failures entry lists what stayed open)
in_flight After an interrupt, each decision the journal still held unsettled: its trigger (interval, teardown, …), intent kind, allocation and the broadcast transaction, null before broadcast. Empty otherwise. transactions, teardown_executed and closed are read from the journal at the interrupt, so teardown work done before it is reported
repayment The runtime's latest repayment coverage per borrowing allocation, as at closure: allocation, asset, block, debt, available, shortfall and the labeled rate projection; empty when no debt was measured
holdings Per fund account, its balances once teardown ends (after an interrupt, at the interrupt: closed is false and in_flight shows what was pending), read from its fork node: each configured asset on its chain with asset, symbol when known, amount in base units and the decimal quantity, zero balances included. These are whole on-chain balances, the fixture's 1% funding buffer included; compare them with the asset the exit should leave the fund in. error replaces balances that could not be read
keepers Per request a fork keeper settled: its request key, kind, chain, the replayed keeper and its source_transaction and source_block on the origin chain, the report_times, the fork transaction and its status (mined or reverted, with the revert reason), and the fork time it advanced_seconds. A mined execution whose receipt carries the request's lifecycle event also names the request_status GMX gave the request (executed, cancelled or frozen); a cancellation adds its reason (GMX's reason string, else the error name for known price and output bounds, else the raw bytes) and reason_bytes. A keeper transaction mines whether GMX executed or cancelled the request
failures Stage and message of anything that stopped the lifecycle early, each with its kind, class and transient, and the refusal's structured details when it has them: a remaining_obligations teardown refusal lists details.remaining, one row per allocation that still holds something, with its allocation, asset and each non-zero amount as a decimal string (supply_scaled, debt_scaled, collateral_enabled, allowance, user_configuration, supply and debt on Aave and Spark; principal, collateral, allowance, supply and debt on Compound; supply_shares, borrow_shares, collateral, allowance and debt on Morpho)
failure_class The class of the admission refusal, else of the first failure; null when nothing failed
passed Admission passed, the deployment closed, the evidence is clean and nothing failed
status entered when passed and an entry executed, not_entered when passed but no entry executed: the strategy only held, or its entry's request was cancelled (admission and teardown alone were exercised), else failed
duration_seconds Wall time of the whole command

The exit code is 0 exactly when passed is true.

While it runs, the test run's coordinator log (logs/coordinator.log in the fork workspace) is a progress stream of JSON lines; production and hosted coordinators never write the three below. Once, when the coordinator starts, a run_metadata line names what the run's amounts and venues read as, from the resolved configuration: assets maps each verified CAIP-19 asset to its symbol and decimals (a chain's native token carries decimals only) and venues maps each allocation to its connector. Each decision_evaluated line also carries reason: the text the strategy passed as context.decision(..., reason=...) with a hold or an intent, cut to 300 characters, or null when it stated none. After each transaction outcome is committed to the checkpoint, a transaction_executed line states it: time (the coordinator clock at commit), allocation, operation (the connector's operation kind, such as approve, swap, supply, borrow, repay, withdraw, revoke, mint or burn), success, tx_hash, gas_paid (asset and base-unit amount paid by the submitter), changes and revert_reason (cut to 300 characters, or null). changes lists the fund's nonzero token balance changes as CAIP-19 asset and signed base-unit amount strings, negative when spent: the connector's receipt-derived execution effects, the amounts the fund inventory is updated with. Approvals, revokes and reverted transactions have none, and a connector whose effects cannot be read logs none rather than an estimate. A coordinator stopped between the commit and the line loses that line; it never logs a transaction it did not commit.

Each refusal and failure carries a structured kind, taken from its source rather than its message: the error kind of a refused input, the supervisor's failure kind, the error kind of the refusal a stopped coordinator logged, the kind of a fork keeper's data error, or else the stage or condition that ended the lifecycle (decision_timeout, admission_timeout, coordinator_stopped, keeper, …). Its class is infra for the kinds whose cause lies outside the strategy (rpc, http, transport, unavailable, gateway_unavailable, timeout, rate_limit, fork_unavailable, unsupported_on_fork, admission_timeout, the coordinator holds that retry without stopping, strategy_worker_timeout and transient_read_hold, and the stops a wall clock or the harness caused: interrupt, decision_timeout, teardown_timeout, worker_timeout (the preloaded strategy worker did not answer during setup, recorded with the supervisor's retryable start failure)) and strategy for every other kind, including one not listed, and for a contract revert, which keeps the rpc kind but is marked reverted by the gateway. transient is true only for an infra failure its source marked transient, one the same run can pass unchanged; decision_timeout, teardown_timeout, worker_timeout, strategy_worker_timeout and transient_read_hold are, interrupt and unsupported_on_fork never are.

A coordinator that keeps a strategy_worker_timeout or transient_read_hold hold (its strategy worker timing out on a starved host, or a provider read the gateway marked transient) retries without stopping, so the test run reads its log: once the hold has lasted 300 seconds without its recovery event, the decision or teardown wait stops with a failure of that kind, its reason in the message and the hold's kind, attempt and elapsed_seconds as details. A briefer hold that recovers does not stop the lifecycle. When a wait does run out, a hold the coordinator is still on, however brief, names the failure instead of decision_timeout or teardown_timeout; without one the timeout keeps its own kind. After a lifecycle that settled no decision, teardown waits at most 180 seconds instead of 900, so the verdict is written within a 20-minute budget; its teardown_timeout message says the wait was bounded. A caller can therefore re-run once only when failure_class is infra and that failure is transient, without reading the message.

A local fork can only execute EVM bytecode. Contracts a chain's node implements natively, such as Base's B20 stock tokens whose code is the single byte 0xef, resolve on mainnet but fail every call on the fork. check --fork --online refuses them in its fork_compatibility stage with code unsupported_on_fork. Fork setup checks the fork nodes before any funding, and again when it reuses a funded fixture, and stops failed with the failure unsupported_on_fork, naming every such contract. No retry or resume can pass it, so status offers shutdown instead of resume, almanak run exits 1 and the verdict's refusal carries "error": "unsupported_on_fork". This is a limitation of local fork simulation, not of the strategy, which can still be exported and deployed.

Parameters the strategy's own validate_config refuses (see authoring) stop setup failed with the final failure invalid_config and the author's message and line as the diagnostic. Status offers shutdown, hosts do not retry it, and the verdict's refusal carries "error": "invalid_config" with class strategy. check --online fails its strategy stage with "error": "invalid_config" and code strategy_config_refused.

A fork node starts at the fork block's own base fee without Anvil's 1 gwei minimum tip, so its gas quote follows the forked chain's market and a mainnet-calibrated strategy.gas fits. On chains whose fee history reports no tips (BNB), the node starts at the upstream gas price instead.

Production

uv run --locked almanak wallet create --account treasury --output /private/operator
uv run --locked almanak check ./my-strategy --production --online \
  --gateway-config /private/operator/production.yaml --env-file /private/providers.env
uv run --locked almanak run ./my-strategy --production \
  --gateway-config /private/operator/production.yaml \
  --env-file /private/providers.env --output /private/runs/live

For Safe/Roles accounts the production online check also reports a permissions block: per Safe, whether its authority is installed and, when it is not, the Safe Transaction Builder batch the owner executes. It is generated exactly as admission generates it, so the owner can install permissions before the run exists. Admission still generates and verifies the installed authority.

The author file carries the economic gas policy under strategy.gas. Resolution retains this policy in every mode. Identified forks enforce the same economic bounds through explicit operator fork.gas ceilings and generated fixture payers. Missing or incompatible ceilings are refused before fixture setup. Fork runs on Base and Optimism refuse authored gas caps because their L1 data and operator fees have no verified bound on a fork; gasless forks are unaffected. Production runs on those chains accept authored caps: the gateway bounds the L1 data and operator fees at quote time within the payer ceilings and reconciles the exact charges from each receipt. Fee-only replacement remains opt-in and limited to its supported Ethereum execution path. The operator file carries the production authority: execution networks, independent payer ceilings that the authored policy must fit, the funded account bindings and the signing references. The online check verifies policy compatibility through the read-only gateway and never opens signing files. EOA funds are their own submitters. Safe funds use delegated Roles operators; owners remain distinct and are never a fallback signer.

Encrypted keystores must match the named submitters. Interactive passwords are passed ephemerally and are not saved in manifests. An explicit private password_file in the operator signing section is an operator choice. A gateway restart requests a password when required. Credentials must be operator-owned private files outside sandbox readable paths.

A hosted operator may replace --key-file with --signer-file, a private JSON binding whose kind names an installed signer plugin (entry-point group almanak.signers). The gateway then holds no key material: every envelope a remote signer returns is decoded and checked against the requested transaction and the bound submitter before it is broadcast or retained. The two flags are exclusive and a remote signer takes no keystore password.

Missing Safe permissions are exported for owner review and installation. The supervisor reports action required and does not install them. The Safe must be a canonical Safe 1.4.1 (L1 or SafeL2) whose only module is its Roles 2.1.0 proxy, with no guard and either no fallback handler or Safe's CompatibilityFallbackHandler 1.4.1. When the operator's role was assigned in the Roles setup transaction, the exported bundle does not assign it again. After owner action, explicit resume verifies installed authority before admitting execution. Installed authority is read from Roles storage at a pinned block: every grant the strategy needs must be installed exactly, with the operator in the role and no default role. Grants beyond the strategy's own, other members of its role and other roles of the operator are not detected; Safes are dedicated to one strategy. The authority check before every plan, signature and broadcast replays the previous check's block-pinned reads in batches at its own observation block. Production advances on canonical mined receipts and audits recorded canonicality before further work. Invalidated history halts with pending evidence retained.

Reporting and advanced commands

Text is the default on terminals; redirected output is JSON. Override with --format text, --format json or --json. Offline reports never obtain credentials or contact a provider. They distinguish retained status from a fresh online audit and preserve fund/payer scope, unavailable values and simulation assumptions. Custom dashboard failure remains visible without hiding the report.

Explicit process management remains under service gateway, service fork, service start, service resume, service status and service teardown. permissions generate|verify uses an authenticated gateway.

Market resolution

almanak markets --out .almanak/markets.json reads, at one pinned block per chain (--block, default latest; the record's blocks names each chain's number and hash, and every market on a chain resolves at that block), what each market in config.yaml resolves to: its contracts, tokens and ids, each under the resolver field that names it (pool, currencies, asset, oracle, ...), with every token's symbol and decimals, for the user to check on an explorer before approving the strategy. It reads no prices, so it is quick; names that resolve to the same contracts (two legs of one lending market) share one entry, and a market without a resolver is listed under skipped. It takes the session's read gateway like almanak ax.

Protocol observations

almanak ax reads one protocol observation at one pinned block. Every subcommand takes one transport and its own request arguments after the subcommand, and every one but the two HyperCore subcommands takes --block (default finalized):

Subcommand --connector Request
pool uniswap_v3, pancakeswap_v3, aerodrome_slipstream, aerodrome, curve --chain and --pool, or --config; optional --depth-bps for the first three
pool-price uniswap_v4 --chain, --currency0, --currency1, --fee, --tick-spacing and optional --base, or --config
lending-reserve aave_v3, sparklend, aave_v4, compound_v3, morpho_blue --chain and the connector's selector flags, or --config
lending-position same as lending-reserve the same selection, plus --account
perp-position gmx_v2 --config, --account
perp-markets gmx_v2 --chain and --query, or --config
balance none --asset (CAIP-19 asset type), --account (repeatable)
resolve none --chain, --query (symbol or address); optional --limit
dex-pools none --chain, --token; optional --quote, --connector, --limit, --offset, --depth-bps
lending-markets aave_v3, sparklend, aave_v4, compound_v3, morpho_blue --chain; optional --asset (required for morpho_blue), --collateral, --include-unlisted (morpho_blue), --limit, --offset
hypercore-markets none optional --query (a coin), --limit, --offset, --include-delisted; no --block
hypercore-account none --account (repeatable); no --block

--config names a JSON request file whose fields are the connector's request model; for the lending subcommands it is the market selection an author writes under markets:. --block accepts a decimal block number, finalized or latest; any other value is refused before a transport opens. The reader first resolves the selector to one header, and every later read of the observation names that header's hash with requireCanonical, so the output's block records the concrete number and hash all values came from. A latest observation can be reorganized away; a later read against a block that is no longer canonical fails instead of mixing blocks.

On HyperEVM (eip155:999), finalized is the head, and Alchemy runs a read pinned within about 128 to 192 blocks of its head at the next block. There a finalized or latest selector pins the block 256 blocks (about four minutes) behind the tag's block, for almanak markets too, and the output names that block. Every HyperEVM block is proved before its first read, so a read the provider still runs at another block is refused as provider_context; an explicit number is pinned as given.

Each observation reads through exactly one transport: --env-file resolves the provider credentials in the calling process, or --gateway-url with --gateway-auth-file reads through service read-gateway, a read-only observation gateway that keeps the credentials in its own process and serves only whitelisted JSON-RPC reads and the discovery lookups below over authenticated loopback. Hosted sessions that must not receive a provider key use the gateway transport. Without either flag, ax reads through the gateway named by ALMANAK_AX_GATEWAY_URL and ALMANAK_AX_GATEWAY_AUTH_FILE, which the hosted worker sets; a flag always wins, and with neither the command is refused. The inline pool-price key names a pool on the reviewed deployment for the chain, hookless unless --hooks names a reviewed hook (whose dynamic-fee profiles take --fee 8388608). Use each command's --help for the complete argument list.

ax lending-reserve resolves a lending market exactly as the author loader does and reports the price sources the protocol uses at the pinned block, with a paste-ready config block:

almanak ax lending-reserve --connector aave_v3 --chain eip155:1 \
  --asset eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599 \
  --block finalized --env-file .env

Selector flags use the author field names: --asset and optional --pool for Aave V3 and SparkLend, --spoke and --reserve-id for Aave V4, --comet and --asset for Compound V3, --market-id, --leg and optional --core for Morpho Blue. The result carries resolution, price_sources, config and the reserve observation. For Aave V3 and SparkLend, config.allocation holds the oracle and price_source an allocation on that market states. For Aave V4, config.debt holds the oracle and debt_price_source of a debt on that reserve, and config.collateral_price_source is the entry for that reserve in a debt's collateral_price_sources. Compound V3 and Morpho Blue resolve their feeds and oracle themselves, so their config.market adds them only as optional assertions. A deployment the connector has not reviewed is refused. A reserve state read the protocol refuses at that block leaves observation null with observation_error, and the price sources are still reported.

Beside the raw observation, rates states the reserve's annual rates from that same read, with no read of its own: supply_apr, supply_apy, borrow_apr and borrow_apy as decimal ratios per year (0.05 is 5%), rounded half-even to 12 places from exact arithmetic, compounding, available_liquidity in the asset's units and the raw protocol values they come from. They are the protocol's rates at the pinned block, not a forecast.

Connector Raw rates APR APY available_liquidity
aave_v3, sparklend liquidity_rate_ray, variable_borrow_rate_ray: per year, in ray rate / 1e27 (1 + APR / Y)^Y - 1 (per_second) Aave V3 virtual_underlying_balance; SparkLend available_underlying
compound_v3 the Comet curves' per-second supply and borrow rates at its recorded utilization, as getSupplyRate and getBorrowRate answer, in 1e18 rate x Y / 1e18 (1 + rate / 1e18)^Y - 1 (per_second) base_available, the Comet's base balance
morpho_blue the IRM's per-second borrowRateView, in 1e18; supply_assets, borrow_assets, fee borrow: rate x Y / 1e18; supply: borrow APR x utilization x (1 - fee) e^APR - 1 for each (continuous, as Morpho accrues): utilization and fee scale the rate before it compounds supply_assets - borrow_assets

Y is 31,536,000 seconds and utilization is borrow over supply assets.

These are the rates of the asset the market lends, named as rates.asset: the reserve's own asset for Aave V3 and SparkLend, the Comet's base asset and Morpho's loan asset. rates.selected_asset states what the selected asset earns: role lent with the market's supply_apr and supply_apy when it is that asset, or role collateral with both zero when it is collateral, which neither a Comet nor a Morpho market lends. Selecting WBTC in a USDC Comet reports USDC's rates under rates and zero under rates.selected_asset. The Aave V4 reserve observation reads no Hub drawn rate or liquidity, so its rates is null with rates_unavailable. A refused observation reports no rates.

risk labels the collateral parameters the health factor and the borrow limit use, in basis points, one collateral row per collateral asset: ltv_bps, liquidation_threshold_bps and liquidation_bonus_bps. Beside them it states the formulas, at the protocol's own oracle prices:

  • health_factor: sum(collateral_value x liquidation_threshold) / debt_value; the position is liquidatable below 1.
  • borrow_limit: debt_value <= sum(collateral_value x ltv).
  • projection: for one collateral and a debt of f times its value, the health factor is liquidation_threshold / f, and the largest f a floor H admits is min(ltv, liquidation_threshold / H).
Connector ltv_bps liquidation_threshold_bps liquidation_bonus_bps
aave_v3, sparklend the reserve configuration's LTV its liquidation threshold its stored bonus - 10000
aave_v4 the collateral factor of the reserve's current dynamic config the same factor maxLiquidationBonus - 10000, paid at or below liquidation.health_factor_for_max_bonus
compound_v3 borrowCollateralFactor / 1e14 liquidateCollateralFactor / 1e14 (1e18 - liquidationFactor) / 1e14, the share an absorbed account loses
morpho_blue lltv / 1e14 the same LLTV liquidation_incentive_factor - 1, where the factor is min(1.15, 1 / (1 - 0.3 x (1 - LLTV)))

Compound V3 and Morpho Blue keep the factors on the market, so one read covers every collateral. On Aave V3, SparkLend and Aave V4 they belong to the collateral's own reserve: call lending-reserve on the collateral asset, not the borrowed one. Aave V3 and SparkLend values are the ordinary-mode ones; eMode and isolation are not applied. Aave V4 also reports the reserve's dynamic_config_key and the Spoke's liquidation config, and states that collateral_risk_bps is the risk premium the collateral adds to the debt rate, not a collateral factor. An Aave V4 risk read the Spoke refuses leaves risk null with risk_error; a refused observation reports no risk.

Each row's max_borrow_fraction.by_ltv is the LTV. Two optional flags project a borrow against that one collateral:

almanak ax lending-reserve --connector aave_v3 --chain eip155:1 \
  --asset eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 \
  --borrow-fraction 0.3 --borrow-fraction 0.5 --health-floor 1.6 \
  --block finalized --env-file .env

--borrow-fraction f (repeatable, above 0 and at most 1) adds a projected row per fraction with health_factor = liquidation_threshold / f and within_borrow_limit (f <= LTV). --health-floor H (above 1, since 1 is the liquidation line) adds max_borrow_fraction.at_floor = min(LTV, liquidation_threshold / H). Values are exact decimals rounded down to 4 places, so neither overstates the margin. They are static ratios at the pinned block: no price move, interest or other collateral is included, and a position with several collaterals combines them as health_factor states.

ax balance reads every repeated --account at the same pinned block.

ax pool reads a Uniswap V3, PancakeSwap V3 or Aerodrome Slipstream pool from only its chain and address, given inline or as a {"chain", "pool"} file. Both forms validate as the same request:

almanak ax pool --connector uniswap_v3 --chain eip155:4663 --pool 0x... \
  --block latest --depth-bps 200 --gateway-url "$URL" --gateway-auth-file "$TOKEN_FILE"

The connector's resolver reads the pair, fee and tick spacing from the pool and refuses a pool whose factory is not the connector's reviewed deployment on that chain, naming the factory the pool reports. The result carries support (verified, or candidate for a chain declared only for qualification), the resolved identities, the pinned observation and the spot price of currency0 in currency1, which is null when the pool has no active liquidity. With --depth-bps, depth.to_lower is the currency1 the pool pays out before its price falls by that many basis points and depth.to_upper the currency0 it pays out before its price rises by as many; both exclude swap fees and are raw integer units. The walk reads every initialized tick inside the band, and a band wider than 5000 basis points or holding more than 2048 initialized ticks is refused rather than truncated.

For an Aerodrome classic or Curve pool, ax pool reports the connector's resolution (the pool's coins, decimals and, for Curve, its family and revision, verified against the reviewed factory or registry) and the pool's prices. An Aerodrome classic report also carries reserves: each currency's stored reserve, the pool's depth, with asset, amount in base units, decimals and the decimal quantity; a Curve report carries its balances in pool_state. --depth-bps is refused for them. Every price, here and for the V3-style pools, is whole units of quote per whole unit of base, fees excluded, and names its kind:

Pool Field kind
V3-style, Aerodrome classic price: currency0 in currency1 spot, the marginal price at the pinned state (Aerodrome from its stored reserves, volatile or stable)
Curve StableSwap prices: each later coin in coin 0 spot, from the invariant as the pool's get_p computes it
Curve TwoCrypto, TriCrypto prices: each later coin in coin 0 last_spot (last_prices, the marginal price after the pool's last swap or liquidity change, not an execution price) and oracle (price_oracle, an EMA)

An Aerodrome classic pool without liquidity on a priced side reports null.

A Curve pool also reports pool_state: balances and total_supply (raw amounts with each asset and its decimals), the fee (Curve's 1e10-scaled integer and its basis points; for TwoCrypto and TriCrypto the mid_fee, out_fee and fee_gamma between which the swap fee moves), amplification (A at the pinned block), and virtual_price (1e18-scaled; StableSwap computes it from the invariant as get_virtual_price does, the crypto pools report their stored value). StableSwap also reports offpeg_fee_multiplier and TwoCrypto and TriCrypto gamma.

A Curve pool is funded when every coin balance and its LP supply are positive. An unfunded pool reports "status": "unfunded_pool", a reason naming each empty balance, prices: null and only its balances and supply: no swap into it can fill and a first deposit would set its price. ax dex-pools reports such a pool as refused with reason unfunded_pool: …, backtest prepare refuses a window in which it is unfunded, and check --online for fork or production refuses it in its pool_liquidity stage with code unfunded_pool, read through the operator's provider at the latest block.

Discovery

ax resolve and ax dex-pools find candidates an author does not yet have. CoinGecko on-chain names the candidates and the chain verifies them; the vendor's liquidity and volume are reported as its figures, in USD cents, and are absent when it omits them. They need COINGECKO_API_KEY (a CoinGecko pro key) in the env file of the calling process or of the read gateway.

almanak ax resolve --chain eip155:8453 --query cbBTC
almanak ax dex-pools --chain eip155:8453 \
  --token eip155:8453/erc20:0x4200000000000000000000000000000000000006 \
  --quote eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 --depth-bps 100

ax resolve takes a symbol or an ERC-20 address. A symbol is matched against the tokens in the pools the vendor's search returns, case-insensitively, by the symbol the vendor lists or the token's own symbol(), and the candidates are ranked by vendor liquidity. An address the vendor does not list is still reported, with its vendor fields null. Every reported candidate is read on chain: decimals() verifies the contract and symbol() is reported as onchain_symbol, null when the token does not answer it as a string. source.complete is false, with the reason in source.limitation, when the vendor has not indexed part of the lookup or the search filled the two pages (40 pools) read, since a candidate may then be missing. recommended names the leading candidate only when no lookup went unindexed, it is verified, its onchain_symbol matches the symbol the vendor lists, and it is either the only candidate or its liquidity is measured at least ten times the next one's measured liquidity, and its own is nonzero; otherwise it is null and the author chooses. A search cut at 40 pools still recommends: liquidity is each token's total across all its pools, and a token large enough to rival the leader has pools among the first 40. A smaller same-symbol token can still be missed; source.limitation reports the cut. A symbol is never identity: the author states the CAIP-19 asset. Native assets are slip44 identities and are not looked up.

ax dex-pools lists the pools the vendor reports for an ERC-20 --token, optionally only those pairing it with an ERC-20 --quote, in the vendor's order (24-hour volume with liquidity), not re-ranked, so an offset continues the vendor's list. The vendor offers no snapshot or cursor, so its order can shift between calls as volumes move. --limit (default 10, at most 20) and --offset page through them; page.matched counts the matches in the pages read and page.has_more says whether a later offset returns more. The vendor's venue label only selects a connector; a label the SDK does not know is matched by the pool's own factory() against each connector's reviewed factory, before --connector filters, so a factory-matched pool is kept. Every reported pool is resolved by its connector, which refuses a pool from an unreviewed deployment and names why. The vendor's pair is only a filter: a pool whose resolved currencies do not include --token (and --quote, when given) is refused with vendor_mismatch. Each pool reports a status:

Status Meaning
verified The connector resolved the pool at the pinned block; market is a paste-ready markets: entry
refused The connector refused the pool, with its reason, the pool it resolved does not trade the requested pair (vendor_mismatch), or a Curve pool is unfunded (unfunded_pool)
unverified Uniswap V4 pools whose key neither the vendor's pair and fee nor the pool's Initialize event states, including a malformed event
unsupported No SDK connector for the venue: neither its label nor a reviewed factory names one

A Uniswap V4 key is first derived from the vendor pair and the fee in its pool name as a hookless pool with the standard tick spacing for that fee; when its hash differs from the vendor's pool ID, the reviewed PoolManager's Initialize event for that ID states the exact key. The connector then admits the key exactly as it does for a strategy: a hooked pool is verified only when its hook has a reviewed profile, the profile's permission bits and fee mode, and code equal to its pin, and the row adds a hook block with the profile, hook address, pin: match and the current terms (a Fables or StablePairHook LP fee per direction, or a Pons hook fee and creator tax in bps). Any other hook is refused with the connector's reason. A pool holding the chain's native coin (ETH, POL, BNB or AVAX) keeps the native coin in its PoolKey and is verified with the fund holding its wrapped token (WETH, WPOL, WBNB or WAVAX): the reviewed Universal Router unwraps the input or wraps the output within the swap, and the reviewed PositionManager does the same for liquidity. The vendor files these pools under the native coin, so a --token of the wrapped token also reads the vendor's native-coin list, a page of each list in turn, and a --quote of the wrapped token keeps them. The native coin itself (slip44) is accepted as --token or --quote and asks for its wrapped token, so it returns the same wrapped and native pools. If the vendor has not indexed one of the two lists, the other is still read and the source is incomplete. The row's markets: entry names the native currency; keep it native, since a selection naming the wrapped token is a different pool. A row the chain did not verify reports the vendor's claimed currencies as unverified_vendor_currencies (the native coin as slip44, null when omitted): a claim that tells a native pool from its wrapped twin, not a verified identity. Native pools on a chain without a reviewed router route, and native/wrapped-native pools, are refused with the connector's reason. Vendor USD figures are cents, and absent when missing, malformed or too large to state. source.complete is true only when the vendor's list ended within the pages read. One call reads at most five vendor pages, the token's 100 busiest pools (shared with the native coin's list for a wrapped-native token), and the vendor does not index every pool; an incomplete list says so in source.limitation, and a pool missing from it may still exist. A token the vendor has not indexed is reported the same way, never as a complete empty list. A known pool, for example one a reference strategy names, is checked directly with ax pool or ax pool-price.

ax lending-markets lists a lending protocol's markets on a chain from the protocol's own records at the pinned block, or, for Morpho Blue where Morpho's API indexes the chain, from that API. None of them needs a key:

almanak ax lending-markets --connector aave_v3 --chain eip155:43114
almanak ax lending-markets --connector morpho_blue --chain eip155:8453 \
  --asset eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913
Connector Record Order and filters
aave_v3, sparklend the reviewed Pool's getReservesList() (source.record pool_reserves) the Pool's order; --asset keeps that reserve
aave_v4 the reserves of each Spoke the chain's Hubs serve (getSpokeAddress, getReserve; hub_spokes) Hub, Spoke and reserve order; --asset keeps reserves of that asset; a Spoke that reverts getReserveCount() holds no reserves and is counted in source.without_reserves; one whose count cannot be read for any other reason (transport, throttling, an undecodable answer) is named in source.unreadable, as is a Hub whose Spokes cannot be enumerated (hub) and a reserve that cannot be read (spoke, reserve_id); the rest still list
compound_v3 the Comets the chain's Configurator recorded in CometDeployed events (comet_deployments) deployment order; --asset keeps Comets whose base or collateral it is, --collateral those accepting it
morpho_blue Morpho's API on Ethereum, Optimism, Polygon, HyperEVM, Robinhood, Base and Arbitrum (source.record morpho_api, with source.skipped counting rows it answered in another shape); elsewhere the markets the reviewed core recorded in CreateMarket events (core_markets) --asset (the loan asset) is required; supplied markets only, most supplied in the loan asset first; --collateral narrows; from the API, only markets Morpho lists unless --include-unlisted (source.listed_only, source.curation morpho_api); from the core's events, every supplied market (listed_only false, curation null)

A record only names a candidate. Each reported market is resolved by its connector as lending-reserve resolves it, and reports status verified with the same config block lending-reserve returns, or refused with the resolver's reason. A Compound V3 selection names the asked-for --asset, base or collateral, or else the base asset; a Comet whose assets cannot be read is named in source.unreadable. A Morpho selection is the market's loan leg; its collateral leg uses the same market_id. From the API, a Morpho row reports only its market_id and the asked-for loan_asset as the market's own; Morpho's view is under vendor: collateral_asset, lltv, supplied and borrowed in the loan asset, supplied_usd and borrowed_usd, supply_apy, Morpho's listed curation and its warnings (for example not_whitelisted or unrecognized_collateral_asset). They come from Morpho's current index, not the pinned block, are absent where it omits them, and are never strategy inputs; a verified row's config is the resolver's reading at the pinned block, and a market whose own loan or collateral asset contradicts the row is refused with vendor_mismatch; a row naming no collateral claims an idle market, whose collateral is the zero address. Anyone can create a Morpho market, and an unlisted one can show supply looped against worthless collateral that still passes the resolver's revision checks, so the API listing keeps to Morpho's listed markets by default; --include-unlisted adds the rest, each with Morpho's warnings. The core's events carry no curation, so a listing from them is not filtered: its source reports listed_only false and curation null, and --include-unlisted changes nothing there. From the core's events, supplied and borrowed are the totals the core last recorded, before interest accrued since; unsupplied markets are counted in source.without_supply, and a listing that would read more than 5,000 markets is refused. A provider that caps eth_getLogs ranges refuses a core's whole history in one request; ours does on Avalanche, where the listing then fails with that reason and lending-reserve still verifies a known market ID. Every reported asset is an object of its CAIP-19 asset and its on-chain symbol(), null where a token does not answer one as a string; the symbol is a label, the asset is the identity. A resolver checks the deployment's revisions, not whether a reserve takes deposits or loans now, so Aave V3 and SparkLend rows also carry the reserve's own reserve flags (active, frozen, paused, borrowing_enabled, ltv_bps, liquidation_threshold_bps, liquidation_bonus_bps) and Aave V4 rows its frozen, paused and borrowable; a frozen reserve, or one with no loan-to-value, cannot back a borrow even when verified. A flag read that fails reports reserve as null. Compound V3 rows add collateral_factors, one per entry of collateral_assets in the same order, and Morpho rows add liquidation_incentive_factor beside lltv (under vendor when the LLTV is Morpho's), each as lending-reserve's risk labels them. A verified row also carries the rates that lending-reserve reports for that market, from the same reserve read; a read the protocol refuses leaves rates null with rates_error and the row still verified, and an Aave V4 row reads nothing for them (rates_unavailable). A row from Morpho's API keeps Morpho's own vendor.supply_apy beside the on-chain rates. --limit (default 10, at most 20) and --offset (at most 500) page through the matches, with page.matched and page.has_more. Only the page's markets are resolved. Without a filter, an Aave V4 listing reads only the page's reserves and a Compound V3 listing only the page's Comets: matched counts every one the record names, and a reserve or Comet that cannot be read is named in source.unreadable only when it falls on the page. With --asset or --collateral, each is read to count the matches, and one that cannot be read is named and not matched. Each event history is asked for from block zero in one request; a provider that refuses that range or result size fails the listing.

HyperCore

Hyperliquid's HyperCore is a venue of HyperEVM (eip155:999): a HyperCore account is the same address on that chain. ax hypercore-markets and ax hypercore-account read it from Hyperliquid's own /info API, which needs no key:

almanak ax hypercore-markets --query BTC
almanak ax hypercore-account --account 0x...

The API has no as-of query, so these two take no --block: an answer is HyperCore's state when the API served it. Each report names every query it made in source or sources, with the time its answer was received in milliseconds (received_at), never a block.

hypercore-markets reads the first perpetual dex's metaAndAssetCtxs. Each market reports its target (dex and coin), its asset index (its position in the dex's universe, the number every order carries), the venue's meta entry (szDecimals, maxLeverage, marginTableId, and isDelisted or marginMode where the venue sets them), the margin_tiers of its margin table, null when meta lists no table of that ID, and its context: mark, oracle and mid prices, funding, open interest and volumes, as the venue writes them. --query reports the one market meta names so, or names so apart from letter case. Without it, tradable markets are listed by the venue's 24-hour notional volume, most traded first; --include-delisted adds delisted ones. --limit (default 20, at most 50) and --offset (at most 500) page through them; matched counts them all. HIP-3 dexes are not read.

hypercore-account reads each --account's role (missing until HyperCore first credits the address; an approved agent reports the account it trades for), its account abstraction mode (abstraction), its perp margin and positions, and its spot balances, its open orders on the first dex (open_orders, spot included, whoever placed them, each with its cloid when it has one) and the agents it approved (agents, each with validUntil, its expiry in milliseconds, or null). Under a unified account or portfolio margin, spot holds every balance and the perp state is not meaningful: perp.state is then not_meaningful, otherwise as_reported. Each position carries its market's asset index. Each spot balance names its HyperEVM asset: the ERC-20 the token is linked to, the USDC ERC-20 for USDC (which HyperCore links to Circle's CoreDepositWallet), the native asset for HYPE, or null for a token linked to no HyperEVM contract, which has no identity. Amounts are decimal strings in the token's HyperCore units (wei_decimals).

Every answer must match the shape recorded from mainnet for its query; an answer with a field added, missing or written differently is refused as malformed rather than read, and an answer for another market, account, order or time range than asked is refused as source_mismatch. The read gateway serves the same queries as its info operation, and ALMANAK_GATEWAY_HYPERLIQUID_WEIGHT_PER_MINUTE caps them by the API's own request weight: 2 for an account's perp or spot state, 60 for its role and 20 for the rest, plus one per 20 items a fill, funding or ledger page returns. Before sending, such a page reserves the most it can cost (120 for fills, 45 for funding payments, 120 for ledger entries) and settles to the count it returned. The ledger charge is the SDK's conservative choice: the API documents no per-item weight for ledger entries. These reservation limits are separate from reconciliation's truncation thresholds (2000 fills, 500 funding payments, 500 ledger entries), which mark a page that may be cut short.