Scaffold this example locally and test it with the CLI:
circuit new --name my-index-agent --language python --template index
cd my-index-agent
circuit run # execute a run cycle
circuit unwind # test the unwind logic
circuit.toml
circuit.toml
name = "Example Index Agent"
tagline = "Weekly equal-weight token index"
category = "index"
imageUrl = "https://api.circuit.org/assets/agents/default"
walletVmFamily = "evm"
allowedExecutionModes = ["auto", "manual"]
[[triggers]]
type = "schedule"
every = 10080 # weekly (7 * 24 * 60)
align = "rolling"
[startingAsset]
network = "ethereum:8453" # Base
address = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC
minimumAmount = "10000000" # 10 USDC (6 decimals)
Example
from circuit_sdk import AgentContext, decimal_to_base_units
NETWORK = "ethereum:8453" # Base
USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # 6 decimals, ~$1
# The basket this index tracks - equal weight across every entry. Swap these
# for the tokens you want exposure to (e.g. the top AI tokens); each must be a
# valid, liquid ERC-20 on NETWORK.
INDEX_TOKENS = [
{"symbol": "WETH", "address": "0x4200000000000000000000000000000000000006"},
{"symbol": "cbBTC", "address": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf"},
]
# All USD math below is exact micro-USD (6-decimal) ints - never float.
MICRO_USD_DECIMALS = 6
def micro_usd(usd: str) -> int:
return int(decimal_to_base_units(usd, MICRO_USD_DECIMALS))
# Skip drift smaller than this so we don't churn fees on tiny moves.
REBALANCE_BAND_MICRO_USD = micro_usd("1")
# One same-chain swap, quoted then executed. Used both ways: USDC -> token to
# build the basket, token -> USDC to unwind it.
def execute_swap(agent: AgentContext, from_token: str, to_token: str, amount_raw: int, label: str) -> None:
limits = {
"from": {"network": NETWORK, "address": agent.session_wallet_address},
"to": {"network": NETWORK, "address": agent.session_wallet_address},
"amount": str(amount_raw),
"fromToken": from_token,
"toToken": to_token,
}
quote = agent.swap.quote(limits)
if not quote.success or not quote.data:
agent.log(f"{label} quote failed: {quote.error}", error=True)
return
result = agent.swap.execute({**limits, "expiresAt": None})
if result.success:
agent.log(f"{label}: ~{quote.data.asset_receive.amount_formatted} expected")
else:
agent.log(f"{label} failed: {result.error}", error=True)
def run(agent: AgentContext) -> None:
# Rebalance the basket back to equal weight: mark each leg's value, split the
# portfolio's total evenly, then trim the overweight legs and top up the
# underweight ones. The first run (all USDC, no tokens yet) is just the
# all-underweight case, so this one path both builds and rebalances the basket.
allocation = agent.allocation
def balance(addr: str):
return next(
(b for b in allocation.balances if b.token_address.lower() == addr.lower()),
None,
)
usdc = balance(USDC)
# USDC is ~$1 and 6-decimal, so its raw units ARE micro-USD - no oracle.
cash_micro_usd = int(usdc.amount_raw) if usdc else 0
legs = []
for token in INDEX_TOKENS:
held = balance(token["address"])
legs.append({
"token": token,
"raw": int(held.amount_raw) if held else 0,
# Marked value from the portfolio, kept exact; bail rather than
# guess if a held leg is unpriced.
"market_value_micro_usd": micro_usd(held.market_value_usd) if held and held.market_value_usd is not None else None,
})
if any(leg["raw"] > 0 and leg["market_value_micro_usd"] is None for leg in legs):
agent.log("A basket token is unpriced - skipping rebalance", error=True)
return
total = cash_micro_usd + sum((leg["market_value_micro_usd"] or 0) for leg in legs)
if total == 0:
agent.log("No funds to allocate")
return
target = total // len(INDEX_TOKENS)
agent.log(f"Rebalancing ${total / 1e6:.2f} to ${target / 1e6:.2f} per token")
# Trim overweight legs first so the USDC proceeds fund the top-ups below.
for leg in legs:
value = leg["market_value_micro_usd"] or 0
excess = value - target
if excess <= REBALANCE_BAND_MICRO_USD or leg["raw"] == 0:
continue
# Sell the fraction of the holding whose value equals the excess.
sell_raw = leg["raw"] * excess // value
execute_swap(agent, leg["token"]["address"], USDC, sell_raw, f"Trim {leg['token']['symbol']}")
# Top up underweight legs from USDC (~$1, so a micro-USD gap is that many 6-decimal units).
for leg in legs:
shortfall = target - (leg["market_value_micro_usd"] or 0)
if shortfall <= REBALANCE_BAND_MICRO_USD:
continue
execute_swap(agent, USDC, leg["token"]["address"], shortfall, f"Add {leg['token']['symbol']}")
def unwind(agent: AgentContext) -> None:
allocation = agent.allocation
# Sell every basket token held back to USDC.
for token in INDEX_TOKENS:
held = next(
(b for b in allocation.balances if b.token_address.lower() == token["address"].lower()),
None,
)
held_raw = int(held.amount_raw) if held else 0
if held_raw == 0:
continue
execute_swap(agent, token["address"], USDC, held_raw, f"Sell {token['symbol']}")
import { type AgentContext, decimalToBaseUnits } from "circuit:sdk";
const NETWORK = "ethereum:8453"; // Base
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // 6 decimals, ~$1
// The basket this index tracks - equal weight across every entry. Swap these
// for the tokens you want exposure to (e.g. the top AI tokens); each must be a
// valid, liquid ERC-20 on NETWORK.
const INDEX_TOKENS = [
{ symbol: "WETH", address: "0x4200000000000000000000000000000000000006" },
{ symbol: "cbBTC", address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" },
] as const;
// All USD math below is exact micro-USD (6-decimal) bigints - never float.
const MICRO_USD_DECIMALS = 6;
const microUsd = (usd: string) => BigInt(decimalToBaseUnits(usd, MICRO_USD_DECIMALS));
// Skip drift smaller than this so we don't churn fees on tiny moves.
const REBALANCE_BAND_MICRO_USD = microUsd("1");
// One same-chain swap, quoted then executed. Used both ways: USDC -> token to
// build the basket, token -> USDC to unwind it.
async function executeSwap(
agent: AgentContext,
fromToken: string,
toToken: string,
amountRaw: bigint,
label: string,
): Promise<void> {
const limits: Parameters<AgentContext["swap"]["quote"]>[0] = {
from: { network: NETWORK, address: agent.sessionWalletAddress },
to: { network: NETWORK, address: agent.sessionWalletAddress },
amount: amountRaw.toString(),
fromToken,
toToken,
};
const quote = await agent.swap.quote(limits);
if (!quote.success || !quote.data) {
await agent.log(`${label} quote failed: ${quote.error}`, { error: true });
return;
}
const result = await agent.swap.execute({ ...limits, expiresAt: null });
if (result.success) {
await agent.log(`${label}: ~${quote.data.assetReceive.amountFormatted} expected`);
} else {
await agent.log(`${label} failed: ${result.error}`, { error: true });
}
}
export async function run(agent: AgentContext): Promise<void> {
// Rebalance the basket back to equal weight: mark each leg's value, split the
// portfolio's total evenly, then trim the overweight legs and top up the
// underweight ones. The first run (all USDC, no tokens yet) is just the
// all-underweight case, so this one path both builds and rebalances the basket.
const allocation = agent.allocation;
const balance = (addr: string) =>
allocation.balances.find((b) => b.tokenAddress.toLowerCase() === addr.toLowerCase());
const usdc = balance(USDC);
// USDC is ~$1 and 6-decimal, so its raw units ARE micro-USD - no oracle.
const cashMicroUsd = usdc ? BigInt(usdc.amountRaw) : 0n;
const legs = INDEX_TOKENS.map((token) => {
const held = balance(token.address);
return {
token,
raw: held ? BigInt(held.amountRaw) : 0n,
// Marked value from the portfolio, kept exact. Bail rather than guess if
// a held leg is unpriced - never rebalance on a fabricated value.
marketValueMicroUsd:
held && held.marketValueUsd != null ? microUsd(held.marketValueUsd) : null,
};
});
if (legs.some((leg) => leg.raw > 0n && leg.marketValueMicroUsd === null)) {
await agent.log("A basket token is unpriced - skipping rebalance", { error: true });
return;
}
const total = cashMicroUsd + legs.reduce((sum, leg) => sum + (leg.marketValueMicroUsd ?? 0n), 0n);
if (total === 0n) {
await agent.log("No funds to allocate");
return;
}
const target = total / BigInt(INDEX_TOKENS.length);
const dollars = (micro: bigint) => `$${micro / 1_000_000n}.${String((micro % 1_000_000n) / 10_000n).padStart(2, "0")}`;
await agent.log(`Rebalancing ${dollars(total)} to ${dollars(target)} per token`);
// Trim overweight legs first so the USDC proceeds fund the top-ups below.
for (const leg of legs) {
const value = leg.marketValueMicroUsd ?? 0n;
const excess = value - target;
if (excess <= REBALANCE_BAND_MICRO_USD || leg.raw === 0n) continue;
// Sell the fraction of the holding whose value equals the excess.
const sellRaw = (leg.raw * excess) / value;
await executeSwap(agent, leg.token.address, USDC, sellRaw, `Trim ${leg.token.symbol}`);
}
// Top up underweight legs from USDC (~$1, so a micro-USD gap is that many 6-decimal units).
for (const leg of legs) {
const shortfall = target - (leg.marketValueMicroUsd ?? 0n);
if (shortfall <= REBALANCE_BAND_MICRO_USD) continue;
await executeSwap(agent, USDC, leg.token.address, shortfall, `Add ${leg.token.symbol}`);
}
}
export async function unwind(agent: AgentContext): Promise<void> {
const allocation = agent.allocation;
// Sell every basket token held back to USDC.
for (const token of INDEX_TOKENS) {
const held = allocation.balances.find(
(b) => b.tokenAddress.toLowerCase() === token.address.toLowerCase()
);
const heldRaw = held ? BigInt(held.amountRaw) : 0n;
if (heldRaw === 0n) continue;
await executeSwap(agent, token.address, USDC, heldRaw, `Sell ${token.symbol}`);
}
}
Sample Output
Agent run started
Rebalancing $102.40 to $51.20 per token
Trim WETH: ~12.30 expected
Add cbBTC: ~0.00012 expected
Agent run completed
Agent unwind started
Sell WETH: ~48.90 expected
Sell cbBTC: ~51.30 expected
Agent unwind completed
How It Works
- Mark the basket: Reads each token’s held amount and current USD value, plus idle USDC (valued from its raw balance, since it’s ~$1).
- Set the target: Splits the portfolio’s total value equally across the basket - that’s each token’s target allocation.
- Trim overweight: For any leg above target by more than the rebalance band, sells just the excess fraction back to USDC.
- Top up underweight: For any leg below target, buys the shortfall with USDC - funded by the trims plus any idle cash.
- Weekly cadence: the schedule trigger’s
everyis set to a weekly interval. The first run (all USDC) builds the basket; later runs correct whatever weights have drifted. - Unwind: Sells every basket token held back to USDC.
Notes
- The basket is yours to curate.
INDEX_TOKENSseeds with WETH and cbBTC as a runnable example - replace them with the tokens you want exposure to (e.g. the top AI tokens). Each must be a valid, liquid ERC-20 on the configured network. - All legs trade on a single network (Base) so swaps are same-chain and settle quickly. Point
startingAssetandNETWORKat another chain if your basket lives elsewhere. - Weights are computed from each balance’s marked
marketValueUsd. If a held token is unpriced the agent skips the run rather than rebalance on a fabricated value - it never coerces a missing price to zero. REBALANCE_BAND_USDis a deadband: drift smaller than it is left alone so the agent doesn’t churn swap fees on noise.agent.swap.quote(...)previews a route. Pass the same limits toagent.swap.execute(...), which gets a fresh executable quote. Always checkresult.successbefore assuming the trade landed.