Skip to content

PancakeSwap Perps

Connector for PancakeSwap Perps (ApolloX Diamond) on BSC. PancakeSwap is broker id 2 on the underlying ApolloX (ASX) perpetual trading platform.

Overview

PancakeSwap Perps is an oracle-priced margin trading venue. The Almanak SDK integrates it through the intent system, supporting PERP_OPEN (full pipeline) and PERP_CLOSE (direct-SDK in v1).

The router is a Diamond proxy (EIP-2535) at 0x1b6f2d3844c6ae7d56ceb3c3643b9060ba28feb0. Trade-path facets: TradingPortal (open/close), TradingOpen / TradingClose (keeper settle), TradingReader (views), PriceFacade (keeper entry).

Market Format

Markets use the same slash separator convention as GMX V2: "BTC/USD", "ETH/USD", "BNB/USD". Each market resolves to a pairBase BSC ERC-20 address (e.g. BTCB, ETH-bsc, WBNB).

Intent.perp_open(
    market="BTC/USD",
    collateral_token="BNB",          # native BNB margin (auto-wraps to WBNB)
    collateral_amount=Decimal("0.3"),
    size_usd=Decimal("500"),
    is_long=True,
    leverage=Decimal("1.5"),
    max_slippage=Decimal("0.01"),
    protocol="pancakeswap_perps",
)

Supported Operations

Intent Description
Intent.perp_open() Open a leveraged long or short position
Intent.perp_close(position_id=<tradeHash>, ...) Close a position by its bytes32 tradeHash. v1 accepts full closes only: size_usd (partial close) is rejected at compile time (CompilationStatus.FAILED). Strategies persist the tradeHash emitted at open and pass it back as position_id. build_close_transaction(trade_hash) remains available as a direct-SDK escape hatch, but the intent-compiler path is the recommended flow.

Keeper Execution Model

PancakeSwap Perps uses a two-step oracle-fill execution model (similar in shape to GMX V2 but driven by Pyth on mainnet):

  1. Pending trade creation — your transaction calls openMarketTrade / openMarketTradeBNB, which emits a MarketPendingTrade(user, tradeHash, trade) event. This is the transaction the SDK signs and submits.
  2. Keeper settlement — an off-chain keeper holding PRICE_FEEDER_ROLE subsequently calls PriceFacadeFacet.requestPriceCallback(priceRequestId, price) (or its Pyth-VAA variant). This invokes marketTradeCallback internally, which either fills the position (OpenMarketTrade event) or refunds it (PendingTradeRefund event) based on slippage / oracle gap checks.

Close follows the same shape: closeTrade(tradeHash) emits a pending close request; keeper settles via closeTradeCallback, emitting CloseTradeSuccessful(user, tradeHash, closeInfo) and one or more CloseTradeReceived(user, tradeHash, token, amount) payout events.

Implications for strategies:

  • on_intent_executed(success=True) fires when the pending TX confirms, not when the keeper settles. The strategy must persist the tradeHash and poll for fill confirmation on subsequent ticks via getPositionByHashV2(tradeHash).

    Extraction shape: ResultEnricher.position_id only accepts integer NFT IDs or 40-char hex addresses, so a 64-hex-char bytes32 tradeHash is surfaced on result.extracted_data["position_id"] rather than result.position_id. Strategies should prefer result.position_id and fall back to result.extracted_data["position_id"]:

    position_id = getattr(result, "position_id", None)
    if position_id is None:
        position_id = (result.extracted_data or {}).get("position_id")
    

  • Filled entry_price is only available after keeper settlement (a separate TX), so extract_entry_price() returns None from the open TX's receipt.
  • The slippage-to-limit-price conversion must produce a fill bound that the keeper considers acceptable AND is within highPriceGapP (1.5% as of v1) of the on-chain oracle's cached beforePrice.

Minimum Position Size

ApolloX enforces TradingConfig.minNotionalUsd (200 USD as of v1) on every open. Notional is computed as price (1e8) × qty (1e10) ÷ 1e18. Strategies opening positions below this floor will revert at the synchronous open call with TradingCheckerFacet: Position is too small.

Collateral Tokens

Chain Supported Margin
BSC BNB (native, via openMarketTradeBNB), WBNB, USDT, USDC

For native BNB margin, the SDK routes through openMarketTradeBNB with the margin sent as msg.value; the router wraps it to WBNB inside the transaction (verifiable in the receipt's WBNB Deposit event). For ERC-20 margin, the intent compiler prepends an approve() transaction.

Markets (v1)

Market pairBase (BSC)
BTC/USD 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c (BTCB)
ETH/USD 0x2170Ed0880ac9A755fd29B2688956BD959F933F8 (ETH-bsc)
BNB/USD 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c (WBNB)

ApolloX also lists synthetic equity markets (NVDA, TSLA, etc.) with ApolloX-issued pairBase contracts. These are out of v1 scope pending gateway-side equity-oracle support; see the design doc follow-ups.

Known Limitations

  • Keeper delay: Position state is not immediately available after the open call. Wait for the keeper-settled OpenMarketTrade event before relying on filled position data.
  • Refunds: If the keeper-quoted oracle price violates the trader's acceptable price (slippage limit), the keeper emits PendingTradeRefund instead of OpenMarketTrade, and the trader's margin is returned. Your strategy must handle this case (poll getPendingTrade going to zero without a corresponding getPositionByHashV2 populated).
  • No SL/TP in v1: The intent vocabulary doesn't yet carry stop-loss / take-profit. The contract supports them; the integration is deferred to a cross-venue (PCS Perps + GMX V2 + Hyperliquid) vocabulary RFC.
  • No Arbitrum: ApolloX has separate deployments per chain. v1 ships BSC only.
  • Full-close only via intents: PerpCloseIntent(position_id=<tradeHash>) compiles to closeTrade(bytes32). Partial closes (size_usd set) are rejected at compile time — ApolloX's closeTrade(bytes32) selector always flattens the whole position. Strategies persist the tradeHash emitted at open and pass it back as position_id. build_close_transaction(trade_hash) remains available for manual transaction construction.

API Reference

almanak.framework.connectors.pancakeswap_perps

Backwards-compatibility shim for pancakeswap_perps.

PancakeSwap Perps is powered by Aster (formerly ApolloX, rebranded March 2025). The canonical connector moved to almanak.framework.connectors.aster_perps (PRD: docs/internal/discussions/aster-dex-integration-20260418.md · VIB-3044).

This shim re-exports every previously-public symbol. Legacy consumers using from almanak.framework.connectors.pancakeswap_perps import ... continue to work unchanged. The shim binds broker_id=2 (PancakeSwap attribution) in every config it constructs, which preserves pre-rebrand behaviour byte-for-byte.

Deprecation: importing from this module emits DeprecationWarning once per process. New strategies must import from aster_perps directly and set protocol="aster_perps" on intents.

SELECTOR_CLOSE_TRADE module-attribute

SELECTOR_CLOSE_TRADE = fromhex('5177fd3b')

closeTrade(bytes32)

SELECTOR_OPEN_MARKET_TRADE module-attribute

SELECTOR_OPEN_MARKET_TRADE = fromhex('703085c7')

openMarketTrade((address,bool,address,uint96,uint80,uint64,uint64,uint64,uint24))

SELECTOR_OPEN_MARKET_TRADE_BNB module-attribute

SELECTOR_OPEN_MARKET_TRADE_BNB = fromhex('b7aeae66')

openMarketTradeBNB((address,bool,address,uint96,uint80,uint64,uint64,uint64,uint24))

CloseTradeReceivedEvent dataclass

CloseTradeReceivedEvent(
    user: str,
    trade_hash: str,
    token: str,
    amount: int,
    log_index: int = 0,
)

TradingCloseFacet.CloseTradeReceived — payout leg of a close.

CloseTradeSuccessfulEvent dataclass

CloseTradeSuccessfulEvent(
    user: str,
    trade_hash: str,
    close_price: int,
    funding_fee: int,
    close_fee: int,
    pnl: int,
    holding_fee: int,
    log_index: int = 0,
)

TradingCloseFacet.CloseTradeSuccessful — fires when keeper fills a close.

MarketPendingTradeEvent dataclass

MarketPendingTradeEvent(
    user: str,
    trade_hash: str,
    pair_base: str,
    is_long: bool,
    token_in: str,
    amount_in: int,
    qty: int,
    price: int,
    stop_loss: int,
    take_profit: int,
    broker: int,
    log_index: int = 0,
)

TradingPortalFacet.MarketPendingTrade — fires on user-signed openMarketTrade.

OpenMarketTradeEvent dataclass

OpenMarketTradeEvent(
    user: str,
    trade_hash: str,
    entry_price: int,
    pair_base: str,
    token_in: str,
    margin: int,
    qty: int,
    is_long: bool,
    open_fee: int,
    execution_fee: int,
    timestamp: int,
    log_index: int = 0,
)

TradingOpenFacet.OpenMarketTrade — fires when keeper fills a pending open.

OpenTradeStruct dataclass

OpenTradeStruct(
    pair_base: str,
    is_long: bool,
    token_in: str,
    amount_in: int,
    qty: int,
    price: int,
    broker: int,
    stop_loss: int = 0,
    take_profit: int = 0,
)

Python mirror of the Aster openMarketTrade input struct.

All integer fields use the on-wire units the contract expects
  • amountIn: collateral-token smallest units (wei-equivalent)
  • qty: 10-decimal fixed-point (e.g. 0.15 BTC = 1500000000)
  • price: 8-decimal fixed-point limit / acceptable price
  • stopLoss: 8-decimal fixed-point (0 = no SL)
  • takeProfit: 8-decimal fixed-point (0 = no TP)
  • broker: uint24 broker id (PancakeSwap = 2, raw Aster = 0)

ParsedReceipt dataclass

ParsedReceipt(
    market_pending_trades: list[
        MarketPendingTradeEvent
    ] = list(),
    open_market_trades: list[OpenMarketTradeEvent] = list(),
    pending_trade_refunds: list[
        PendingTradeRefundEvent
    ] = list(),
    close_trade_successful: list[
        CloseTradeSuccessfulEvent
    ] = list(),
    close_trade_received: list[
        CloseTradeReceivedEvent
    ] = list(),
)

Aggregate view of decoded Aster Perps events in a receipt.

PendingTradeRefundEvent dataclass

PendingTradeRefundEvent(
    user: str,
    trade_hash: str,
    refund_code: int,
    log_index: int = 0,
)

TradingOpenFacet.PendingTradeRefund — fires when keeper refunds a pending trade.

PerpOpenOrderResult dataclass

PerpOpenOrderResult(
    success: bool,
    error: str | None = None,
    tx: AsterPerpsTx | None = None,
    pair_base: str | None = None,
    margin_token_address: str | None = None,
    qty: int = 0,
    limit_price: int = 0,
    native: bool = False,
    amount_in_wei: int = 0,
)

Adapter output for a compiled PERP_OPEN.

Attributes:

Name Type Description
success bool

False if validation fails before tx construction.

error str | None

description when success is False.

tx AsterPerpsTx | None

the built transaction.

pair_base str | None

resolved pairBase address for the market.

margin_token_address str | None

resolved margin-token address (or NATIVE sentinel).

qty int

computed on-wire qty (uint80, 10-decimal).

limit_price int

computed acceptable limit price (uint64, 8-decimal).

native bool

True if the transaction uses openMarketTradeBNB (value-carrying).

amount_in_wei int

margin amount in token-wei (matches OpenTradeStruct.amountIn).

encode_close_trade_calldata

encode_close_trade_calldata(
    trade_hash: str | bytes,
) -> bytes

Encode calldata for closeTrade(bytes32).

Parameters:

Name Type Description Default
trade_hash str | bytes

32-byte position identifier (hex string or raw bytes).

required

Returns:

Type Description
bytes

4-byte selector + abi-encoded bytes32.

encode_get_pending_trade_calldata

encode_get_pending_trade_calldata(
    trade_hash: str | bytes,
) -> bytes

Encode calldata for the getPendingTrade(bytes32) view.

encode_get_position_by_hash_calldata

encode_get_position_by_hash_calldata(
    trade_hash: str | bytes,
) -> bytes

Encode calldata for the getPositionByHashV2(bytes32) view.

encode_open_market_trade_calldata

encode_open_market_trade_calldata(
    trade: OpenTradeStruct, *, native: bool = False
) -> bytes

Encode calldata for openMarketTrade or openMarketTradeBNB.

Parameters:

Name Type Description Default
trade OpenTradeStruct

populated OpenTradeStruct

required
native bool

when True uses openMarketTradeBNB (native BNB margin via msg.value), when False uses openMarketTrade (ERC20 margin, requires prior approve).

False

Returns:

Type Description
bytes

4-byte selector + abi-encoded tuple.

get_margin_token_address

get_margin_token_address(
    symbol: str, chain: str = "bsc", token_resolver=None
) -> str

Resolve a margin-token symbol (WBNB/USDT/USDC) to its BSC ERC-20 address.

Accepts either a symbol or a 0x-prefixed EVM address (passed through after validation). For native BNB, pass symbol='BNB' or 'NATIVE' and the NATIVE_BNB_ADDRESS sentinel is returned (the caller must use encode_open_market_trade_calldata(native=True)).

Symbol resolution goes through the framework's unified :func:almanak.framework.data.tokens.get_token_resolver so the connector stays in sync with the rest of the token-metadata surface (aliases, on-chain fallbacks, disk cache). The local :data:ASTER_PERPS_TOKENS allowlist is only consulted to reject tokens that the Aster router does not accept as margin; the address for allowed symbols comes from the resolver.

Parameters:

Name Type Description Default
symbol str

Margin token symbol (e.g. 'WBNB', 'USDT', 'USDC', 'BNB', 'NATIVE') or a 0x-prefixed EVM address.

required
chain str

Chain key (default 'bsc').

'bsc'
token_resolver

Optional TokenResolver override; defaults to the singleton returned by get_token_resolver().

None

Raises:

Type Description
ValueError

If the symbol is not one of Aster Perps' supported margin tokens.

TokenNotFoundError

If the resolver cannot resolve an allowed symbol to an on-chain address on chain (propagated unchanged).

get_pair_base

get_pair_base(market: str, chain: str = 'bsc') -> str

Resolve a market symbol (e.g. 'BTC/USD') to the on-chain pairBase address.

Accepts either a registered symbol (v1: BTC/USD, ETH/USD, BNB/USD) or a 0x-prefixed EVM address (passed through after validation — lets PerpOpenIntent.market carry the pairBase address directly for synthetic / non-registered markets).

Raises ValueError if the symbol is not registered and is not a valid address. Non-crypto markets (NVDA, TSLA, ...) use synthetic ApolloX-issued pairBases and are deferred to v2 per the design doc; callers wanting to hit them today must pass the pairBase address explicitly.

get_router_address

get_router_address(chain: str = 'bsc') -> str

Return the Aster Perps router address for the given chain.

slippage_to_limit_price

slippage_to_limit_price(
    mark_price: Decimal, slippage: Decimal, *, is_long: bool
) -> int

Convert (mark_price, slippage_fraction) into the price field the router expects.

For longs: acceptable price = mark_price * (1 + slippage) — trader willing to pay up to this. For shorts: acceptable price = mark_price * (1 - slippage) — trader willing to receive down to this.

Returned value is uint64 with 8-decimal scaling.

usd_size_to_qty

usd_size_to_qty(
    size_usd: Decimal, mark_price: Decimal
) -> int

Convert a USD notional to on-wire qty (uint80, 10-decimal fixed-point).

qty = size_usd / mark_price, then scale to 1e10 (see QTY_DECIMALS).

Raises ValueError on non-positive inputs (keeps the connector fail-fast — silently coercing zero/negative sizes into bogus positions is exactly the class of bug the 'no quick patches' guardrail forbids).

PancakeSwapPerpsConfig

PancakeSwapPerpsConfig(
    chain: str = "bsc",
    wallet_address: str | None = None,
    broker_id: int = PANCAKESWAP_PERPS_BROKER_ID,
) -> AsterPerpsConfig

Backwards-compat factory mimicking the old dataclass signature.

The legacy PancakeSwapPerpsConfig had broker_id defaulted to 2. The canonical AsterPerpsConfig requires broker_id with no default so raw-aster callers are forced to pick 0 explicitly. This factory preserves the old default (=2) for every call that comes through the shim.

build_open_transaction

build_open_transaction(
    *,
    chain: str = "bsc",
    wallet_address: str | None = None,
    broker_id: int = PANCAKESWAP_PERPS_BROKER_ID,
    **open_kwargs,
) -> PerpOpenOrderResult

Legacy build_open_transaction signature: defaults broker_id=2 (PCS).

build_close_transaction

build_close_transaction(
    *,
    trade_hash,
    chain: str = "bsc",
    wallet_address: str | None = None,
    broker_id: int = PANCAKESWAP_PERPS_BROKER_ID,
) -> AsterPerpsTx

Legacy build_close_transaction signature: defaults broker_id=2 (PCS).