Installation & Setup
The ArcaSDK Swift package provides a native iOS/macOS client for the Arca platform. It uses Swift structured concurrency (async/await), Codable models, and actor-based thread safety. Requires iOS 15+ / macOS 12+. Zero third-party dependencies.
Installation (Swift Package Manager)
Add the package dependency in your Package.swift or via Xcode:
// Package.swiftdependencies: [ .package(url: "https://github.com/arcaresearch/arca-swift-sdk.git", from: "0.1.0"),],targets: [ .target(name: "MyApp", dependencies: ["ArcaSDK"]),]Or in Xcode: File → Add Package Dependencies → enter the repository URL. If SPM reports no versions available, use Branch: main instead of a version rule.
Initialization
The Swift SDK authenticates with a scoped JWT token minted by your backend. The realm is extracted from the token claims automatically.
import ArcaSDK
// Recommended: with automatic token refreshlet arca = try Arca( token: scopedJwt, tokenProvider: { try await myBackend.getArcaToken() })
// Or provider-only (fetches the first token automatically)let arca = try await Arca.withTokenProvider { try await myBackend.getArcaToken()}
// Static token (manual refresh via updateToken())let arca = try Arca(token: scopedJwt)
// Explicit realm override (if the token doesn't contain realmId)let arca = try Arca(token: scopedJwt, realmId: "rlm_01abc")Token Refresh
Scoped tokens are short-lived (default 60 min). The SDK provides two approaches to handle expiry:
Recommended: Token Provider (automatic)
Pass a tokenProvider closure that calls your backend to mint a fresh token. The SDK handles the rest:
- Proactive refresh — ~30 seconds before expiry
- 401 retry — if a request fails with 401, the SDK calls the provider, swaps the token, and retries the request once
- WebSocket — on reconnect, the provider is called for a fresh credential
Concurrent refresh calls are deduplicated — only one in-flight request at a time.
Manual: updateToken()
If you prefer full control, use updateToken() to swap the token yourself. If the WebSocket is disconnected, it reconnects immediately with the new token.
await arca.updateToken(newScopedJwt)Manual: reconnect()
If your app uses custom lifecycle events (e.g. returning to foreground in a complex SwiftUI app) and the automatic WebSocket reconnect is not firing quickly enough, you can manually force a reconnect:
await arca.reconnect()Auth Error Event
Register an onAuthError listener to handle unrecoverable authentication failures — e.g., when a 401 is received and no provider is configured, or when the provider itself throws.
let id = await arca.onAuthError { error in showSessionExpiredUI()}
// Remove the listener when no longer neededawait arca.removeAuthErrorHandler(id: id)History Cache
The SDK caches responses from getEquityHistory(), getPnlHistory(), and getCandles() in an in-memory LRU cache. This eliminates redundant network calls when switching between charts or toggling intervals. The watchEquityChart(), watchPnlChart(), and watchCandleChart() methods also benefit since they call the history methods internally.
By default, the cache holds up to 50 entries. You can customize this via the cache parameter:
// Custom cache sizelet arca = try Arca(token: scopedJwt, cache: CacheConfig(maxEntries: 100))
// Disable caching entirelylet arca = try Arca(token: scopedJwt, cache: .disabled)
// Manually clear the cache (e.g. after a known data change)await arca.clearHistoryCache()Authentication Model
The Swift SDK is designed for frontend/mobile apps. It authenticates exclusively with scoped JWT tokens minted by your backend via POST /auth/token. No API key auth or admin operations are supported — those are the responsibility of your backend. See Architecture Patterns for guidance on which operations to expose to the frontend via scoped tokens versus routing through your backend.
If the token does not embed a realmId claim, provide it explicitly in the initializer.
Operation Handles
All mutation methods (ensureDenominatedArca, fundAccount, transfer, placeOrder, etc.) return an OperationHandle<T> — a handle that starts the HTTP call immediately and provides progressive disclosure of the operation lifecycle.
Simple: One-liner to Settlement
// Await full settlement in one linetry await arca.fundAccount(arcaRef: "/wallets/main", amount: "1000").settledProgressive Disclosure
let handle = arca.fundAccount(arcaRef: "/wallets/main", amount: "1000")
// Get the HTTP response immediately (operation may still be pending)let response = try await handle.submittedprint(response.poolAddress ?? "no pool")
// Wait for full settlementtry await handle.settled
// Or with an explicit timeouttry await handle.settled(timeoutSeconds: 15)Batching with async let
async let d1 = arca.fundAccount(arcaRef: "/wallets/main", amount: "500").settledasync let d2 = arca.fundAccount(arcaRef: "/wallets/savings", amount: "300").settledlet (r1, r2) = try await (d1, d2)OrderHandle
placeOrder returns an OrderHandle with additional exchange lifecycle methods for fills and cancellation:
let order = arca.placeOrder( path: "/op/order/btc-1", objectId: exchangeId, market: "hl:0:BTC", side: .buy, orderType: .market, size: "0.01")
// Wait for placementtry await order.settled
// Wait for filllet filled = try await order.filled(timeoutSeconds: 30)print(filled.order.avgFillPrice ?? "")
// Stream fills as they arrivefor try await fill in order.fills() { print("Filled \(fill.size) @ \(fill.price)")}
// Get structured fill summary (P&L, fee breakdown, direction, resulting position)let summary = try await order.fillSummary()print(summary?.realizedPnl ?? "", summary?.direction ?? "", summary?.feeBreakdown ?? "")
// Callback-based fillslet unsub = order.onFill { fill in print("Got fill: \(fill.size)")}// later: unsub()
// Cancel the ordertry await order.cancel().settled
// Resize a sized order (limit / sized TP/SL). Unsized triggers are rejected.try await order.resize("0.75").settledThe order path is the idempotency key. Server-side wrappers should use a stable path per logical order attempt and return once the order has been submitted, rather than waiting inside an HTTP request for placement or fill settlement.
API Summary
| Property / Method | Returns | Description |
|---|---|---|
.submitted | Response | HTTP response before settlement |
.settled | Response | Wait for full settlement |
.settled(timeoutSeconds:) | Response | Wait with explicit timeout |
.filled(timeoutSeconds:) | SimOrderWithFills | Wait for order fill (OrderHandle only) |
.fillSummary(timeoutSeconds:) | Fill? | Get fill with P&L, fee breakdown, direction (OrderHandle only) |
.fills(timeoutSeconds:) | AsyncThrowingStream<SimFill> | Stream fills (OrderHandle only) |
.onFill(callback) | () -> Void | Callback per fill, returns unsub closure (OrderHandle only) |
.cancel(path:) | OperationHandle | Cancel the order (OrderHandle only) |
.resize(_:path:) | OperationHandle | Resize a sized order; unsized triggers rejected (OrderHandle only) |
Behind the scenes, settlement waiting uses WebSocket operation.updated events for real-time detection with periodic HTTP polling as a safety net — no manual polling required.
Arca Objects
ensureDenominatedArca
Ensure a denominated Arca object exists at the given path. Creates it if it doesn't exist; returns the existing one if it does (matching type). Safe to call on every request. Denominated and exchange objects are USD-denominated; the field is server-stamped and not a request input.
Returns an OperationHandle — use .settled to wait for the object to be active (immediate for existing objects).
try await arca.ensureDenominatedArca( ref: "/wallets/main", operationPath: "/op/create/wallets/main:1" // optional idempotency key).settledrefStringrequiredmetadataString?operationPathString?ensureArca
Ensure an Arca object of any type exists at the given path. Creates if missing, returns existing if matching. Returns an OperationHandle.
try await arca.ensureArca( ref: "/deposits/d1", type: .deposit).settledrefStringrequiredtypeArcaObjectTyperequired.denominated, .exchange, .deposit, .withdrawal, .escrow.metadataString?operationPathString?getObject / listObjects
let obj = try await arca.getObject(path: "/wallets/main")print(obj.denomination, obj.status)
// Recovery-hatch state: nil for active boundaries, populated when the// recovery-key holder has acted on-chain. UIs should disable mutating// actions on the object when boundary != nil — the server returns// 409 BOUNDARY_FROZEN otherwise.if let boundary = obj.boundary { print(boundary.status, boundary.recoveryArcaPath ?? "")}
let list = try await arca.listObjects(prefix: "/wallets")print(list.total)Each ArcaObject may include an optional boundary: BoundarySnapshot? describing the recovery-hatch state of the object's isolation boundary. The field is nil for active boundaries (the happy path) and populated when the boundary is soft_frozen or hard_frozen. soft_frozen is reversible (recovery key holder may unlock); hard_frozen is terminal — the platform sweeps every wallet in the boundary into a system-owned recovery arca at recoveryArcaPath and refuses new operations on the boundary.
getObjectDetail
Get full object detail including operations, events, state deltas, and balances.
let detail = try await arca.getObjectDetail(objectId: obj.id.rawValue)print(detail.operations.count)print(detail.balances)print(detail.reservedBalances)getBalances / getBalancesByPath
let balances = try await arca.getBalances(objectId: obj.id.rawValue)for balance in balances { print(balance.denomination, balance.amount)}
// Or by pathlet balances = try await arca.getBalancesByPath(path: "/wallets/main")browseObjects
S3-style hierarchical browsing. Returns folders (path prefixes) and objects at the given level.
let result = try await arca.browseObjects(prefix: "/users/")getObjectVersions
Get all versions of an Arca object at the same path (for deleted + recreated objects).
let versions = try await arca.getObjectVersions(objectId: obj.id.rawValue)getSnapshotBalances
Get historical balances and positions for an object at a specific timestamp.
let snapshot = try await arca.getSnapshotBalances( objectId: obj.id.rawValue, asOf: "2026-02-18T18:00:00Z")ensureDeleted
Delete an Arca object by path. Returns an OperationHandle. If the object has remaining balances, provide a sweep target. For exchange objects with open positions, set liquidatePositions: true to close them via market order first.
// Simple deletiontry await arca.ensureDeleted(ref: "/wallets/old").settled
// Deletion with balance sweeptry await arca.ensureDeleted( ref: "/wallets/old", sweepTo: "/wallets/main").settled
// Exchange deletion with position liquidationtry await arca.ensureDeleted( ref: "/exchanges/hl1", sweepTo: "/wallets/main", liquidatePositions: true).settledrefStringrequiredsweepToString?liquidatePositionsBoolTransfers & Fund Account
transfer
Execute a transfer between two Arca objects. Returns an OperationHandle. The operation path serves as the idempotency key.
let result = try await arca.transfer( path: "/op/transfer/alice-to-bob-1", from: "/wallets/alice", to: "/wallets/bob", amount: "250.00").settledprint(result.operation.state) // .completedpathStringrequiredfromStringrequiredtoStringrequiredamountStringrequiredfeeOverrideString?"0" to disable the fee. Non-production realms only.fundAccount
Fund an Arca object (denominated or exchange). Returns an OperationHandle — use .settled to wait for settlement, or .submitted for the immediate response (includes pool address for on-chain funding). For exchange objects, the deposit is forwarded to the venue after on-chain confirmation.
Settlement behavior depends on realm type:
- Development: Auto-mints simulated tokens and settles within seconds.
- Production: Creates a deposit intent with a
poolAddress. The operation stays pending until real USDC arrives on-chain.
For user-facing deposit flows in production, use createPaymentLink() instead.
// Wait for full settlementtry await arca.fundAccount(arcaRef: "/wallets/main", amount: "1000").settled
// Or get the HTTP response first, then waitlet handle = arca.fundAccount(arcaRef: "/wallets/main", amount: "1000")let response = try await handle.submittedprint(response.poolAddress ?? "simulated")try await handle.settledarcaRefStringrequiredamountStringrequireddefundAccount
Withdraw funds from an Arca object to an external on-chain address. Returns an OperationHandle. When on-chain custody is active, funds are sent to the destination address. For exchange objects, first transfer funds to a denominated object, then defund from there. This is a developer tool — for production withdrawal flows, use createPaymentLink().
try await arca.defundAccount( arcaPath: "/wallets/main", amount: "500.00", destinationAddress: "0xabc...").settledarcaPathStringrequiredamountStringrequireddestinationAddressString?Payment Links
Create shareable URLs for deposits and withdrawals. End users can complete the payment without authentication — the link token serves as the credential.
createPaymentLink
Create a payment link for a deposit or withdrawal. Returns an OperationHandle.
let response = try await arca.createPaymentLink( type: .deposit, arcaRef: "/wallets/main", amount: "100.00", returnUrl: "https://example.com/thanks", expiresInMinutes: 60).submitted // .submitted for the link URL; .settled to wait for paymentprint(response.paymentLink.url) // shareable linkprint(response.paymentLink.status) // "pending"typePaymentLinkTyperequired.deposit or .withdrawal.arcaRefStringrequiredamountStringrequiredreturnUrlString?expiresInMinutesInt?metadata[String: Any]?listPaymentLinks
List payment links in the realm, optionally filtered by type and status.
let response = try await arca.listPaymentLinks(type: .deposit, status: "pending")for link in response.paymentLinks { print(link.id, link.status, link.amount, link.denomination)}typePaymentLinkType?statusString?"pending", "completed", "expired".Operations & Events
getOperation
Get operation detail by ID, including correlated events and state deltas.
let detail = try await arca.getOperation(operationId: "op_01abc")print(detail.operation.type, detail.operation.state)print(detail.events.count, detail.deltas.count)listOperations
List operations in the realm, optionally filtered by type.
// Single typelet list = try await arca.listOperations(type: .transfer)
// Multiple types with inline contextlet history = try await arca.listOperations( types: [.fill, .transfer], includeContext: true)typeOperationType?.transfer, .create, .delete, .deposit, .withdrawal, .swap, .order, .fill, .cancel, .feeDistribution, .adjustment, .funding, .venueClose, or .twap. Unknown future values decode as .unknown(String) so stream delivery can continue.types[OperationType]?type.includeContextBoolDisplay Helpers
Convenience methods for rendering operation history in strategy UIs.
// Transfer direction relative to an objectlet dir = op.transferDirection(for: "/exchanges/strat-1") // .incoming or .outgoing
// Friendly counterparty labellet label = op.counterpartyLabel(for: "/exchanges/strat-1") // "Vault"
// Context accessorslet amount = op.context?.transferAmount // "5000"let fee = op.context?.transferFee // "0.05"let denom = op.context?.transferDenomination // "USD"listEvents / getEventDetail
let events = try await arca.listEvents()let detail = try await arca.getEventDetail(eventId: events.events[0].id.rawValue)listDeltas
List state deltas for a given Arca path (before/after values for every state change).
let deltas = try await arca.listDeltas(arcaPath: "/wallets/main")nonce
Reserve the next unique nonce for a path prefix. Returns a path suitable for use as an idempotency key.
let nonce = try await arca.nonce(prefix: "/op/transfer/fund")try await arca.transfer( path: nonce.path, from: "/wallets/alice", to: "/wallets/bob", amount: "100").settledsummary
Get aggregate counts for the realm.
let summary = try await arca.summary()print(summary.objectCount, summary.operationCount, summary.eventCount)waitForOperation
Wait for a specific operation to reach a terminal state. Uses WebSocket operation.updated events for real-time settlement detection with periodic HTTP polling as a safety net. In most cases, you should use .settled on an OperationHandle instead.
do { let completed = try await arca.waitForOperation( operationId: "op_01abc", timeoutSeconds: 30 ) print(completed.state) // .completed} catch ArcaError.operationFailed(let op) { print("Failed:", op.failureMessage ?? op.state.rawValue)}This is useful when your backend initiates an operation (e.g. via the TypeScript SDK) and your client needs to observe the outcome. Pass the operationId from the backend response and call waitForOperation on the client — it resolves when the operation settles and throws ArcaError.operationFailed(operation:) on failure. The failureMessage field contains a human-readable reason.
Aggregation & P&L
Server-Authoritative Pricing (forward-compatible)
The SDK advertises a server-authoritative-pricing capability on every request — an X-Arca-Client-Capabilities header on REST calls and a capabilities field in the WebSocket auth message. Valuation payloads — ObjectValuation, PathAggregation, and ExchangeState — carry an optional pricingMode field (.client or .server). When the server sends .server, the SDK trusts the server-computed USD values verbatim instead of recomputing them locally from raw mid prices (and watchMaxOrderSize sources its sizing from getActiveAssetData rather than the live mid). When pricingMode is absent or .client, valuation is byte-for-byte identical to prior SDK versions. This contract is behavior-neutral today — it exists so a future simulated-account price overlay can be enabled server-side without a client upgrade. No code changes are required to adopt it.
Prefix vs. Exact Path
All aggregation methods accept a prefix parameter. A trailing slash aggregates all objects under that path (e.g., /users/alice/ returns all of Alice's objects). Omitting the trailing slash targets a single exact object (e.g., /users/alice/exchanges/hl1 returns only that object).
getPathAggregation
Get aggregated valuation for objects matching the prefix (or exact path).
// Aggregate all objects under a prefixlet agg = try await arca.getPathAggregation(prefix: "/users/alice/")print(agg.totalEquityUsd, agg.breakdown.count)
// Single objectlet single = try await arca.getPathAggregation(prefix: "/users/alice/exchanges/hl1")
// Historical aggregation at a past timestamplet hist = try await arca.getPathAggregation( prefix: "/users/", asOf: "2026-02-15T00:00:00Z")Returns a PathAggregation:
prefixStringtotalEquityUsdStringdepartingUsdStringarrivingUsdString?breakdown[AssetBreakdown]asOfString?cumInflowsUsdString?flowsSince time (only present when a watch was created with flowsSince).cumOutflowsUsdString?flowsSince time (only present when a watch was created with flowsSince).Per-object valuations are not included in the REST response. Use getObjectValuation(path:) for a one-shot read, or watchObject(path:exchange:) / watchObjects(paths:exchange:) for streaming.
getPnl
Get P&L for objects matching the prefix (or exact path) over a time range.
let pnl = try await arca.getPnl( prefix: "/users/alice/", from: "2026-02-01T00:00:00Z", to: "2026-03-01T00:00:00Z")print(pnl.pnlUsd) // e.g. "1200.00"Returns a PnlResponse:
prefixStringfromStringtoStringstartingEquityUsdStringendingEquityUsdStringnetInflowsUsdStringnetOutflowsUsdStringpnlUsdStringendingEquity - startingEquity - netInflows + netOutflows.externalFlows[ExternalFlowEntry]getEquityHistory
Get equity time-series sampled evenly over a time range (default 1000 points, max 1000). The backend ladder picks the finest resolution that fits inside points; the default targets 5m for 24h, 1h for 1M, and 4h for 3M ranges.
let history = try await arca.getEquityHistory( prefix: "/users/", from: "2026-02-01T00:00:00Z", to: "2026-03-01T00:00:00Z", points: 100)Returns an EquityHistoryResponse:
prefixStringfromStringtoStringpointsIntequityPoints[EquityPoint]timestamp (String) and equityUsd (String, 2 decimal places).Portfolio Aggregation Watches
Aggregation watches provide real-time portfolio valuation for any combination of Arca objects: total equity, asset breakdown, and pass-through in-transit totals. Structural updates stream as PathAggregation snapshots; for per-object detail, use watchObject(path:exchange:) or watchObjects(paths:exchange:).
createAggregationWatch
Create a watch that tracks objects matching the given sources. Returns the watch ID and an initial aggregation snapshot. After creation, listen for aggregation.updated events via the WebSocket event stream.
// Track all objects under a user prefixlet watch = try await arca.createAggregationWatch(sources: [ AggregationSource(type: .prefix, value: "/users/alice/"),])print(watch.aggregation.totalEquityUsd)
// Combine multiple source typeslet combo = try await arca.createAggregationWatch(sources: [ AggregationSource(type: .prefix, value: "/users/alice/"), AggregationSource(type: .paths, value: "/treasury/usd,/treasury/btc"), AggregationSource(type: .pattern, value: "/users/*/exchanges/hl/*"),])Source types: .prefix (path prefix), .pattern (glob with * wildcard), .paths (comma-separated exact paths), .watch (compose another watch by ID).
watchAggregation(sources:exchange:flowsSince:)
Subscribe to real-time aggregation updates with a single call. Internally creates a server-side watch, watches for structural change events and mid prices, and performs client-side revaluation so every emission reflects live prices. Call stop() when done — it cleans up everything automatically.
Pass flowsSince (ISO 8601 timestamp) to receive cumulative inflow/outflow values (cumInflowsUsd/cumOutflowsUsd) computed from that point forward on every aggregation update.
let stream = try await arca.watchAggregation(sources: [ AggregationSource(type: .prefix, value: "/users/alice/"),])
for await agg in stream.updates { print("Equity:", agg.totalEquityUsd) print("Breakdown entries:", agg.breakdown.count)}
// With flow tracking from a specific timelet flowStream = try await arca.watchAggregation( sources: [AggregationSource(type: .prefix, value: "/users/alice/")], flowsSince: "2026-03-01T00:00:00Z")for await agg in flowStream.updates { print("Inflows:", agg.cumInflowsUsd ?? "n/a") print("Outflows:", agg.cumOutflowsUsd ?? "n/a")}
// Clean up when doneawait stream.stop()The stream emits on both structural changes (fills, balance updates, object creation/deletion) and mid-price ticks. The aggregation property always holds the latest snapshot.
Manual Wiring (Advanced)
For full control you can use createAggregationWatch() with manual WebSocket and price subscriptions.
// 1. Create the watchlet watch = try await arca.createAggregationWatch(sources: [ AggregationSource(type: .prefix, value: "/users/alice/"),])var portfolio = watch.aggregation
// 2. Listen for structural updateslet aggStream = await arca.ws.aggregationEvents()Task { for await (_, agg, _) in aggStream { if let agg = agg { portfolio = agg } }}
// 3. Revalue on mid price tickslet prices = try await arca.watchPrices()for await mids in prices.updates { portfolio = portfolio.revalued(with: mids) renderPortfolio(portfolio)}getWatchAggregation / destroyAggregationWatch
// Get current aggregation on demandlet agg = try await arca.getWatchAggregation(watchId: watch.watchId)
// Destroy when done (also auto-evicted after 5 min of inactivity)try await arca.destroyAggregationWatch(watchId: watch.watchId)watchPnlChart(path:from:to:points:exchange:anchor:)
Create a live P&L chart that merges historical P&L data with real-time aggregation updates and operation events. The anchor parameter controls the y-axis baseline:
anchorPnlAnchor.zero (default) for standard P&L starting at 0, or .equity to map historical points to their actual historical equityUsdand the live point to the current account equity. When set to .equity, each PnlPoint includes a valueUsd field suitable for the chart y-axis. This provides a true historical portfolio value view.// Equity-anchored P&L chart (portfolio value view)let chart = try await arca.watchPnlChart( path: "/users/alice/", from: "2026-02-01T00:00:00Z", to: "2026-03-01T00:00:00Z", anchor: .equity)
for await update in chart.updates { // Use valueUsd for the y-axis — the live point equals current equity for point in update.points { print(point.timestamp, point.valueUsd ?? point.pnlUsd) }}Exchange (Perps)
Trade simulated perpetual futures on exchange Arca objects. These methods wrap the sim-exchange API and follow the same idempotency contract as other operations.
ensurePerpsExchange
Ensure a perps exchange Arca object exists at the given path. Returns an OperationHandle. Denomination is automatically set to USD.
try await arca.ensurePerpsExchange(ref: "/exchanges/hl1").settledrefStringrequiredoperationPathString?getExchangeState
Get exchange account state including equity, margin, positions, and open orders. Use marginSummary for account equity and available balance — see getActiveAssetData below for computing max order sizes. The marginSummary fields are the authoritative source for exchange account balances — there is no separate usdBalance property exposed.
let state = try await arca.getExchangeState(objectId: exchangeId)print(state.marginSummary.equity) // total equity (deposited + unrealized PnL)print(state.marginSummary.totalRawUsd) // total deposited USD (before PnL)print(state.marginSummary.availableToWithdraw) // withdrawable (equity minus maintenance margin)print(state.positions)print(state.openOrders)
// Per-position cost accumulators (running totals over the position's// current open lot, reset on full close or flip-through-zero):if let pos = state.positions.first { print(pos.cumulativeFunding) // funding paid (-) or received (+) on this lot print(pos.cumulativeFee) // total trading fee on this lot (sum of the three below) print(pos.cumulativeExchangeFee) // exchange (taker / maker) component print(pos.cumulativePlatformFee) // platform component print(pos.cumulativeBuilderFee) // builder component}updateLeverage / getLeverage
Set or get the leverage for a coin. Leverage is a per-coin setting, not per-order.
// Set leveragelet result = try await arca.updateLeverage( objectId: exchangeId, market: "hl:0:BTC", leverage: 10)
// Get leverage for a coinlet setting = try await arca.getLeverage(objectId: exchangeId, market: "hl:0:BTC")objectIdStringrequiredmarketStringrequiredleverageIntrequiredThe returned LeverageSetting includes marginMode(.cross or .isolated).
updateIsolatedMargin
Add or remove collateral from an isolated-margin position. A positiveamount (decimal USD string) moves balance into the position, lowering its liquidation price; a negative amount removes collateral, raising it. Removal is rejected if it would drop the position below its maintenance margin. Only valid on isolated positions.
let result = try await arca.updateIsolatedMargin( objectId: exchangeId, market: "hl:1:CL", amount: "25" // add $25; use a negative string to remove)print(result.isolatedMargin, result.liquidationPrice)objectIdStringrequiredmarketStringrequiredamountStringrequiredsetMarginMode
Switch an asset between cross and isolated margin. Rejected on isolated-only (HIP-3) markets and while an open position exists for the asset — close the position first. Leverage is remembered per mode, so switching restores the leverage last set for that mode.
let result = try await arca.setMarginMode( objectId: exchangeId, market: "hl:0:BTC", marginMode: .isolated)print(result.marginMode)objectIdStringrequiredmarketStringrequiredmarginModeMarginModerequired.cross or .isolated.placeOrder
Place a market or limit order. Returns an OrderHandle with full order lifecycle methods (see Operation Handles). If leverage is omitted, the order uses the account's current per-coin leverage setting. Reduce-only orders never reconfigure stored leverage but still carry leverage through to the venue so Hyperliquid can identify the isolated-margin bucket.
Isolated margin. Isolated-only markets — those whose marginModes is ["isolated"] (e.g. hl:1:CL) — reject cross orders at Hyperliquid's matching engine. Pass isolated: true with a positive leverage when opening or increasing on those markets; a reduceOnly close/trim is accepted without leverage (it uses the position's current leverage and never re-margins the remainder). Margin mode is independent of HIP-3 — some HIP-3 markets (e.g. hl:1:TSLA) are cross-eligible. closePosition() auto-fills both fields from the position and market metadata; you only need to set them yourself when calling placeOrder directly.
let order = arca.placeOrder( path: "/op/order/btc-buy-1", objectId: exchangeId, market: "hl:0:BTC", side: .buy, orderType: .market, size: "0.01", leverage: 5)try await order.settled // wait for placementlet filled = try await order.filled(timeoutSeconds: 30) // wait for fillfor try await fill in order.fills() { /* ... */ } // stream fillstry await order.cancel().settled // cancelClose Position (Market, close-only)
The preferred way to close a position is closePosition() — it auto-fills leverage from the position and isolatedfrom the market's marginModes. A reduce-only close no longer requires leverage, but pre-filling it lets a partial close preserve the remainder's leverage. Pass isolated or leverage to override the inference.
// Auto-fills leverage + isolated from the position and market metatry await arca.closePosition( path: "/op/close/btc-1", objectId: exchangeId, market: "hl:0:BTC").settled
// Partial closetry await arca.closePosition( path: "/op/close/btc-partial", objectId: exchangeId, market: "hl:0:BTC", size: "0.005").settledFor limit-price closes, call placeOrder directly with reduceOnly: true. Reduce-only orders skip the margin check and never reconfigure stored leverage.
try await arca.placeOrder( path: "/op/order/btc-close-1", objectId: exchangeId, market: "hl:0:BTC", side: .sell, orderType: .market, size: "0.01", reduceOnly: true).settledReverse After Exit (two-step)
If you support reversal UX, do it in two operations: close-only first, then a separate opposite-side order. Do not submit a single order at 2× the position size — this will fail with an insufficient-margin error when the position is underwater, because the realized loss on close consumes the released margin and leaves nothing for the new leg. The two-step approach guarantees the close always succeeds (reduce-only orders skip the margin check), and the second leg can be evaluated independently.
To determine whether a reverse is feasible before showing the option, compare the streamed maxSellSize / maxBuySize from watchMaxOrderSize() against twice the exit size. If maxSellSize < 2 × exitSize (for a long), disable the reverse toggle — the account doesn’t have enough margin to open the opposite position after closing.
pathStringrequiredobjectIdStringrequiredmarketStringrequiredhl:0:BTC, hl:0:ETH).sideOrderSiderequired.buy or .sell.orderTypeOrderTyperequired.market or .limit.sizeStringrequiredpriceString?.limit orders.leverageInt?reduceOnlyBool?isTriggerBool?triggerPx and tpsl.triggerPxString?isMarketBool?price as the limit after trigger.tpslTpslType?.takeProfit ("tp") or .stopLoss ("sl"). Required when isTrigger is true.sizeToMaxBool?nil/false for a sized TP/SL that closes its fixed size (reduce-only). Either way, no TP/SL outlives the position.Order status includes .waitingForTrigger and .triggered alongside the usual cases (open, filled, etc.).
Position TP/SL (ergonomic)
Attach a stop-loss or take-profit to an existing position with setStopLoss / setTakeProfit. Each looks up the open position, infers the closing side (LONG → .sell, SHORT → .buy), and by default places a reduce-only unsized (sizeToMax: true) order, so when it fires it closes the entire live position regardless of size, and it is cancelled when the position closes or is liquidated. Pass a base-unit size to scale out only part of the position instead — the leg becomes a sized reduce-only partial (capped at the live position; min-notional waived). Leverage and isolated are auto-filled from the position and market meta. Both return an OrderHandle and throw ArcaError.notFound when there is no open position for the coin.
// Stop-loss on a long position → reduce-only SELL triggertry await arca.setStopLoss( path: "/op/tpsl/btc-sl", objectId: exchangeId, market: "hl:0:BTC", triggerPx: "54000").submitted
// Take-profittry await arca.setTakeProfit( path: "/op/tpsl/btc-tp", objectId: exchangeId, market: "hl:0:BTC", triggerPx: "72000").submittedA new trigger replaces any existing position TP/SL of the same type by default (replace: true); pass replace: false to stack. setPositionTpsl attaches both legs at once (at least one of stopLossPx / takeProfitPx required) and returns SetPositionTpslResult (stopLoss / takeProfit handles), deriving <path>/sl and <path>/tp. The two legs are linked as a true one-cancels-the-other bracket: both are stamped with one opaque ocoGroupId, so when either leg fills (even partially) the venue cancels the sibling with cancelReason == "sibling_filled" (readable on the cancelled order via getOrder/listOrders). Pass an explicit ocoGroupId to reuse a known group. Use stopLossSz / takeProfitSz (base units) for sized partial legs; because a partial fill cancels its OCO sibling, setPositionTpsl does not auto-link the legs when either is sized — so a half-size take-profit won't cancel the stop on the remainder. clearPositionTpsl cancels the resting position triggers for a coin (optionally one tpsl leg) and returns the cancelled orders.
// Bracket a position with both legslet result = try await arca.setPositionTpsl( path: "/op/tpsl/btc", objectId: exchangeId, market: "hl:0:BTC", stopLossPx: "54000", takeProfitPx: "72000")
// Later: clear both legs (or pass tpsl: .stopLoss / .takeProfit for one)let cleared = try await arca.clearPositionTpsl( path: "/op/tpsl/btc-clear", objectId: exchangeId, market: "hl:0:BTC")pathStringrequiredsetPositionTpsl, legs derive /sl and /tp.objectIdStringrequiredmarketStringrequiredhl:0:BTC).triggerPxStringrequiredsetStopLoss / setTakeProfit).sizeString?setStopLoss / setTakeProfit (base units). Omit for the default whole-position close.stopLossPx / takeProfitPxString?setPositionTpsl; at least one required.stopLossSz / takeProfitSzString?setPositionTpsl (base units). When set, that leg is a sized reduce-only partial and the legs are not auto-OCO-linked.isMarketBool?true). When false, limitPrice is required.replaceBooltrue).leverageInt?isolatedBool?tpslTpslType?clearPositionTpsl only — restrict to .stopLoss or .takeProfit. Omit to clear both.openWithBracket
Open a position and attach its reduce-only TP/SL triggers in one atomic batch (Hyperliquid normalTpsl parity). Unlike chaining placeOrder then setPositionTpsl, the entry and its triggers are submitted as a single signed batch to one operation: the whole bracket validates and commits at the venue, or none of it does. The trigger legs arm only when the entry fills (they can't fire on mark price before the position exists), and the venue links them with a shared one-cancels-the-other group. At least one of takeProfitPx / stopLossPx is required. Each trigger leg closes the whole position by default; set takeProfitSz / stopLossSz (base units) to make it a sized reduce-only partial (size is the entry size, not the trigger size). Returns OpenBracketResult with one OrderHandle per leg (entry, takeProfit?, stopLoss?), all backed by the single bracket operation. Because the legs share one server-stamped OCO group, a partial take-profit fill cancels the stop on the remainder — to “scale out half and keep a stop on the rest”, open the bracket with only the sized takeProfitSz and attach the (unsized) stop separately via setStopLoss. Use setPositionTpsl instead to attach TP/SL to an already-open position.
let bracket = try arca.openWithBracket( path: "/op/bracket/btc-1", objectId: exchangeId, market: "hl:0:BTC", side: .buy, size: "0.01", takeProfitPx: "72000", stopLossPx: "58000")
try await bracket.entry.settle() // bracket placed_ = try await bracket.entry.filled() // wait for the entry to fully fill// bracket.takeProfit / bracket.stopLoss arm automatically once the entry fillspathStringrequiredobjectIdStringrequiredmarketStringrequiredhl:0:BTC).sideOrderSiderequired.buy / .sell).sizeStringrequiredorderTypeOrderType.market). .limit requires price.takeProfitPx / stopLossPxString?takeProfitSz / stopLossSzString?triggersAreMarketBooltrue).groupingStringnormalTpsl).listOrders / getOrder / cancelOrder / modifyOrder
// List orderslet orders = try await arca.listOrders(objectId: exchangeId)
// Get a specific order with fill historylet detail = try await arca.getOrder(objectId: exchangeId, orderId: orderId)print(detail.order.status, detail.fills.count)
// Cancel an open order (returns OperationHandle)try await arca.cancelOrder( path: "/op/cancel/btc-1", objectId: exchangeId, orderId: orderId).settled
// Resize a resting order. Only sized orders can be resized (limit orders and// sized TP/SL triggers); unsized (sizeToMax: true) triggers are rejected.try await arca.modifyOrder( path: "/op/modify/btc-1-0.75", objectId: exchangeId, orderId: orderId, newSize: "0.75").settledplaceTwap / cancelTwap / getTwap / listTwaps / watchTwap
TWAP (Time-Weighted Average Price) orders execute a total size over a duration by placing market order slices at regular intervals. Duration up to 30 days. Active TWAPs are auto-cancelled on account liquidation. cancelTwap is idempotent for terminal-state TWAPs (already completed, cancelled, or failed): it returns the existing record with no error rather than 4xx.
// Start a TWAPlet twap = try await arca.placeTwap( exchangeId: exchangeId, path: "/wallets/main/twap/btc-accumulate", market: "hl:0:BTC", side: .buy, totalSize: "10.0", durationMinutes: 120, intervalSeconds: 30)print(twap.twap.status) // .active
// Cancel (idempotent for terminal-state TWAPs)try await arca.cancelTwap(exchangeId: exchangeId, operationId: twap.operationId)
// Get status (now includes expectedSliceCount, targetPrice, failureReason)let status = try await arca.getTwap(exchangeId: exchangeId, operationId: twap.operationId)print(status.twap.executedSize, status.twap.filledSlices)print(status.twap.expectedSliceCount, status.twap.targetPrice ?? "n/a")
// Live stream a single TWAP — handles initial REST fetch + WebSocket updatesfor await twap in arca.watchTwap(exchangeId: exchangeId, operationId: twap.operationId) { print("\(twap.sliceCount)/\(twap.expectedSliceCount ?? 0)", twap.status) if twap.status != .active { break }}
// List active TWAPslet active = try await arca.listTwaps(exchangeId: exchangeId, activeOnly: true)
// Get limits + duration-keyed recommendation curve (cached after first call)let limits = try await arca.getTwapLimits()let interval = try await arca.recommendedIntervalSeconds(for: 60) // 5m for hour-long TWAPslistPositions
Each position includes returnOnEquity (unrealized P&L / margin used) and positionValue (size × mark price), computed server-side.
let positions = try await arca.listPositions(objectId: exchangeId)for pos in positions { print(pos.market, pos.side, pos.size, pos.unrealizedPnl ?? "0") print("ROE:", pos.returnOnEquity ?? "N/A", "Value:", pos.positionValue ?? "N/A")}getActiveAssetData
Get active trading data for a coin on an exchange object. Returns a one-shot snapshot of max order sizes. For live updates that react to margin, position, and price changes, use watchMaxOrderSize() instead.
let data = try await arca.getActiveAssetData(objectId: exchangeId, market: "hl:0:BTC")// Use maxBuySize / maxSellSize for order size sliderslet maxLong = Double(data.maxBuySize) ?? 0let maxShort = Double(data.maxSellSize) ?? 0
// Pass leverage to override the stored settinglet data10x = try await arca.getActiveAssetData( objectId: exchangeId, market: "hl:0:BTC", leverage: 10)marketStringleverageLeverageInfotype is .cross or .isolated.maxBuySizeStringmaxSellSizeStringmaxBuyUsdStringmaxSellUsdStringavailableToTradeStringmaxBuyUsd/maxSellUsd.markPxStringfeeRateString"0.00045" for 4.5 bps). Includes exchange taker fee, platform fee, and application fee.maintenanceMarginRateString"0.03" for 3%).bidPxString?markPx when no order book is available.askPxString?markPx when no order book is available.Arca.orderBreakdown(options:)
Pure static calculator (no network call) that converts between three order input modes. Use it to show fee previews and order breakdowns before submitting. When you also want a liquidation estimate, pass both maintenanceMarginRate andaccountContext: the helper computes a cross-margin estimate using the same formula the backend uses, and merges any existing same-coin position the same way it would after a fill.
// Without liquidation estimatelet preview = Arca.orderBreakdown(options: OrderBreakdownOptions( amount: "200", amountType: .spend, leverage: 10, feeRate: data.feeRate, price: data.markPx, side: .buy, szDecimals: 5))
// With cross-margin liquidation estimatelet state = try await arca.getExchangeState(exchangeId)let mmr = Double(data.maintenanceMarginRate) ?? 0let otherMM = (state.positions ?? []) .filter { $0.market != "hl:0:BTC" } .reduce(0.0) { sum, p in let sz = Double(p.size) ?? 0 let entry = Double(p.entryPrice) ?? 0 return sum + mmr * sz * entry }let existing = (state.positions ?? []).first { $0.market == "hl:0:BTC" }let b = Arca.orderBreakdown(options: OrderBreakdownOptions( amount: "200", amountType: .spend, leverage: 10, feeRate: data.feeRate, price: data.markPx, side: .buy, szDecimals: 5, maintenanceMarginRate: data.maintenanceMarginRate, accountContext: OrderBreakdownAccountContext( equity: state.marginSummary.equity, otherMaintenanceMargin: String(otherMM), existingPosition: existing.map { OrderBreakdownExistingPosition(side: $0.side, size: $0.size, entryPrice: $0.entryPrice) } )))// b.tokens, b.notionalUsd, b.marginRequired, b.estimatedFee, b.totalSpend// b.estimatedLiquidationPrice — cross-margin estimate for the post-fill positionTimestamps in Market Data
Market data methods (getCandles, getSparklines, candle watch streams) use Unix epoch milliseconds (UTC) for all time values. This includes the startTime / endTime parameters on getCandles() and the t field on each Candle object.
Daily (.oneDay) candles open at UTC midnight boundaries. Use Int(Date().timeIntervalSince1970 * 1000) for the current time — no timezone conversion is needed.
Other API timestamps (operations, events, objects) use RFC 3339 UTC strings (e.g. 2026-03-31T14:30:00.000000Z). Only market data uses numeric epoch milliseconds.
Market Metadata
Global market data endpoints — not scoped to a specific exchange object. Call getMarketMeta() as the public asset catalog to discover supported assets, canonical IDs, category labels, icon metadata, and trading constraints. Assets listed by Hyperliquid are returned even when Arca has not curated a display name or logo yet.
getMarketMeta()
Returns the full perps universe with per-asset constraints.
Market IDs are case-sensitive. Use asset.name exactly as returned for orders, leverage, active asset data, candles, order books, and price streams. Use asset.symbol, asset.venueSymbol, and asset.displayName only for display or venue links. For example, Shiba Inu is "hl:0:kSHIB", not "hl:0:KSHIB".
let meta = try await arca.getMarketMeta()for asset in meta.universe { print(asset.name, asset.categoryLabel ?? "Unmapped", asset.maxLeverage, asset.szDecimals)}Each Market in universe contains:
nameString"hl:0:BTC", "hl:0:kSHIB", or "hl:1:TSLA" for HIP-3 assets).symbolString"BTC", "kSHIB"). Display-only — do not reconstruct API market IDs from this field.venueSymbolString?"BTC", "xyz:TSLA"). Do not pass this to Arca APIs; use name.exchangeString"hl" for Hyperliquid).dexString?assetTypeString?crypto, equity, commodity, index, forex, etf, hl-native, or stablecoin. Omitted for newly listed venue assets that have not been mapped yet.categoryLabelString?assetType, suitable for UI grouping.mappedBool?false means the asset is live on Hyperliquid but category metadata is not available yet.hasDisplayNameBool?displayName is available.hasLogoBool?logoUrl or logoSources entry is available.descriptionStatusString?curated when Arca has display metadata, otherwise hl_only for assets known only from the live venue listing.szDecimalsIntszDecimals: 4 means sizes must be multiples of 0.0001. Truncate or round order sizes to this precision before submitting.maxLeverageIntmarginModes[String]?["isolated"] for isolated-only markets, ["cross", "isolated"] otherwise. Read this instead of inferring from isHip3 — margin mode is independent of HIP-3 (some HIP-3 markets, e.g. hl:1:TSLA, are cross-eligible). Orders that open or increase on isolated-only markets must include isolated: true with a positive leverage; cross orders are rejected with a 400. A reduceOnly close is accepted without leverage. closePosition() auto-fills both.onlyIsolatedBoolmarginModes instead. true is equivalent to marginModes being ["isolated"] (isolated-only). Removed in a future major version.candleHistoryCandleHistoryBounds?earliestMs (absolute earliest candle timestamp, including extended pre-listing data) and hlEarliestMs (earliest venue-native candle timestamp). When earliestMs < hlEarliestMs, extended history is available. Use earliestMs as startTime for a “Max” chart button.displayNameString?"Bitcoin", "Crude Oil"). Use for UI labels; fall back to symbol when absent.logoUrlString?logoSources[LogoSource]?url, format, and width.isHip3Bool?deployerDisplayNameString?feeScaleDouble?1 for standard perps; greater than 1 for builder-deployed perps where the deployer set a deployerFeeScale.market(_:)
Look up a single market by its canonical id. This is an exact-id lookup — pass the name field of a Market (e.g. "hl:0:BTC"), not a bare symbol like "BTC". To go from a human symbol to its market(s), use resolveMarkets(_:) below. Lazily fetches and caches market metadata on first call — subsequent lookups return instantly from cache without a network request.
let btc = try await arca.market("hl:0:BTC")print(btc?.symbol) // "BTC"print(btc?.displayName) // nil or "Bitcoin"print(btc?.logoUrl) // "https://...-128.webp" (default 128px)print(btc?.logoSources?.first?.width) // 256print(btc?.maxLeverage) // 50print(btc?.szDecimals) // 5
// HIP-3 markets work the same waylet tsla = try await arca.market("hl:1:TSLA")print(tsla?.displayName) // "Tesla"print(tsla?.isHip3) // trueprint(tsla?.venueSymbol) // "xyz:TSLA"print(tsla?.deployerDisplayName) // "xyz"
// Returns nil for an unknown id — and for a bare symbol, which is// never a canonical id (use resolveMarkets for symbols).let nope = try await arca.market("hl:0:DOESNOTEXIST") // nillet alsoNil = try await arca.market("BTC") // nilresolveMarkets(_:exchange:dex:) / resolveMarketOrThrow(_:exchange:dex:)
Go from a human symbol ("BTC", "TSLA") to the market(s) that carry it. A single symbol can legitimately map to many markets — across exchanges and across HIP-3 dexes — so resolveMarkets returns an array. An empty array is an explicit “no market has this symbol”, never a silent guess. Matching is exact and case-sensitive on Market.symbol ("kSHIB" ≠ "KSHIB"); narrow ambiguous symbols with the optional exchange / dex arguments.
Use resolveMarketOrThrow for the “I expect exactly one” case — it throws when zero markets match (so a typo never silently no-ops) or when more than one matches (so an ambiguous symbol never silently resolves to the wrong market). If you already hold a canonical id, use market(_:) instead.
// One symbol can map to many markets — resolveMarkets returns them all.let all = try await arca.resolveMarkets("BTC") // every market whose symbol is "BTC"
// Narrow with exchange / dex when a symbol is ambiguous.// (dex is the HIP-3 deployer DEX name; native Hyperliquid perps omit it.)let btc = try await arca.resolveMarketOrThrow("BTC", exchange: "hl")try await arca.placeOrder( path: "/op/order/btc-buy-1", objectId: exchangeId, market: btc.name, // pass the canonical id, e.g. "hl:0:BTC" side: .buy, orderType: .market, size: "0.01").settled
// HIP-3 markets are namespaced by their deployer DEX:let tsla = try await arca.resolveMarketOrThrow("TSLA", dex: "xyz")
// No match is an explicit empty array, not a guess:let none = try await arca.resolveMarkets("NOTATICKER") // []preloadMarketMeta() / refreshMarketMeta()
Call preloadMarketMeta() at app startup to warm the cache eagerly and avoid latency on the first market(_:) call. Call refreshMarketMeta() to force re-fetch metadata (e.g. after a new asset is listed).
// Warm the cache at startuptry await arca.preloadMarketMeta()
// Force re-fetch when metadata may have changedtry await arca.refreshMarketMeta()Other Market Endpoints
// Current mid priceslet mids = try await arca.getMarketMids()
// L2 order booklet book = try await arca.getOrderBook(market: "hl:0:BTC")
// OHLCV candleslet candles = try await arca.getCandles(market: "hl:0:BTC", interval: .oneMinute)// candles.candles[0].t, .o, .h, .l, .c, .v, .n
// Sparklines — 24 hourly close prices for all coins in one request.// Pre-computed every ~5 minutes in the background; all tracked coins are// always included. For real-time price info, use mid prices or candle subscriptions.let sparklines = try await arca.getSparklines()// sparklines.sparklines["hl:0:BTC"] => [60100.5, 60200.1, ...] (24 hourly close prices)// Newly listed coins may have fewer points until candle history accumulates.Candle Subscriptions (WebSocket)
Subscribe to real-time candle updates for specified coins and intervals. Use the ref-counted acquireCandles / releaseCandles API to safely share subscriptions across components.
arca.ws.acquireCandles(coins: ["hl:0:BTC", "hl:0:ETH"], intervals: [.oneMinute, .fiveMinutes])
for await event in await arca.ws.candleEvents() { print(event.market, event.interval, event.candle)}
arca.ws.releaseCandles(coins: ["hl:0:BTC", "hl:0:ETH"], intervals: [.oneMinute, .fiveMinutes])Real-time Streaming
The SDK provides high-level watch*() methods that manage the WebSocket connection, authentication, path-scoped watches, snapshots, and automatic reconnection. Each method returns a stream object with updates (an AsyncStream) and a stop() function for cleanup.
All watch*() methods work with both API keys and scoped JWT tokens. When using Arca(token:), the WebSocket authenticates with the same token and respects its scope.
watchBalances(arcaRef:)
Use watchBalances() for any view that shows account balances. It delivers an initial snapshot plus real-time updates. Use getBalancesByPath() only for one-shot reads.
Watch real-time balance updates, optionally filtered by path prefix. The server sends a full snapshot on watch, then pushes incremental updates.
// Watch all balanceslet stream = try await arca.watchBalances()
// Or filter by path prefixlet stream = try await arca.watchBalances(arcaRef: "/wallets/main")
// Initial snapshot is available immediatelylet snapshot = stream.balances.value// [String: BalanceSnapshot] — keyed by object ID
// React to updates via structured concurrencyfor await (entityId, event) in stream.updates { let path = event.entityPath ?? entityId print("Balance changed on \(path)")}
// Stop when the view disappearsawait stream.stop()BalanceSnapshot contains entityId, entityPath, and balances: [ArcaBalance].
watchOperations()
Subscribe to real-time operation creates and state changes. The operations property contains the running list, populated from the server's initial snapshot and updated as events arrive.
let stream = try await arca.watchOperations()print(stream.operations.value.count) // initial operations
for await (operation, event) in stream.updates { guard operation.state.isTerminal else { continue } if operation.state == .failed || operation.state == .expired { print("Failed:", operation.failureMessage ?? "unknown") } else { print("Completed:", operation.type, operation.path) }}
await stream.stop()Use this for ambient observation of all operation outcomes — for example, showing error toasts when transfers fail. Filter by sourceArcaPath / targetArcaPath to scope to the current user's objects.
getObjectValuation(path:)
Get the valuation for a single Arca object. Uses the same computation path as aggregation (Axiom 10: Observational Consistency).
let valuation = try await arca.getObjectValuation(path: "/strategies/alpha")print("Equity:", valuation.valueUsd)watchObject(path:exchange:)
Subscribe to real-time valuation updates for a single Arca object. Returns an ObjectWatchStream that emits ObjectValuation updates on structural changes and mid price ticks. The SDK automatically watches mid prices and revalues positions client-side, so valueUsd, unrealizedPnl, and markPrice update in real time without consuming server bandwidth.
let stream = try await arca.watchObject(path: "/strategies/alpha")
for await valuation in stream.updates { print("Equity:", valuation.valueUsd) print("Positions:", valuation.positions ?? [])}
await stream.stop()watchObjects(paths:exchange:)
Subscribe to real-time valuations for multiple Arca objects. Returns an ObjectsWatchStream that emits [String: ObjectValuation] on every update from any watched object. Optional exchange defaults to "sim". Call stop() to tear down all underlying watches.
let stream = try await arca.watchObjects(paths: [ "/users/alice/main", "/users/bob/main",])
for await valuationsByPath in stream.updates { for (path, valuation) in valuationsByPath { print(path, valuation.valueUsd) }}
await stream.stop()revalued(with:)
The ObjectValuation and PathAggregation types provide a revalued(with:) method for client-side recomputation using fresh mid prices. For PathAggregation, spot breakdown entries are revalued; exchange and perp entries pass through until the next server push; departingUsd and arrivingUsd are USD pass-through and unchanged. Used internally by watchObject, watchObjects, and watchAggregation, and available for manual use.
let revalued = valuation.revalued(with: ["hl:0:BTC": "60000", "hl:0:ETH": "3000"])let revaluedAgg = aggregation.revalued(with: mids)watchCandleChart(market:interval:count:)
Create a live candlestick chart with a single method call. The stream loads historical candles, then continuously merges live WebSocket events. The first update contains the full historical array, and every subsequent update includes the complete blended candle array — your app just renders update.candles. Reconnection gaps are filled automatically.
let stream = try await arca.watchCandleChart( market: "hl:0:BTC", interval: .oneMinute, count: 300 // historical candles to load)
for await update in stream.updates { // update.candles — full sorted array (historical + live) // update.latestCandle — the candle that triggered this update renderChart(update.candles)}
await stream.stop()Loading a specific range
When the chart viewport changes (zoom, resize, jump to date), call stream.ensureRange(start, end) with the time range you need. The SDK tracks which ranges have already been fetched, loads only the gaps, coalesces overlapping calls, and merges everything into the sorted candle array.
// Chart zoom-out — tell the SDK what range is now visible:let result = await stream.ensureRange(newVisibleStart, newVisibleEnd)// result.loadedCount == 0 means the range was already loaded, or an overlapping// in-flight ensureRange finished covering it before this call completed.// result.reachedStart == true means no more history existsLoading older candles
For simple backward scrolling, stream.loadMore(count) fetches older candles before the current earliest. Returns a LoadRangeResult with the count of newly loaded candles and whether the start of history was reached.
let result = await stream.loadMore(200)if result.reachedStart { // No more history available}watchCandles(coins:intervals:)
Low-level stream of raw candle events. Each event contains a single candle — your app must maintain the chart array manually. For candlestick charts, use watchCandleChart() above instead.
let stream = try await arca.watchCandles( coins: ["hl:0:BTC", "hl:0:ETH"], intervals: [.oneMinute, .fiveMinutes])
for await event in stream.updates { print(event.market, event.interval, event.candle.close)}
await stream.stop()watchMaxOrderSize(options:)
Subscribe to a live, SDK-derived max order size stream for a specific coin and side. The SDK recomputes continuously from getExchangeState(), watchPrices(), and real-time exchange state updates for responsive UI sizing, while order submission remains backend-authoritative. A single getActiveAssetData fetch at setup resolves the per-asset maintenanceMarginRate (used to populate ActiveAssetData.maintenanceMarginRate for orderBreakdown's liquidation estimate), the asset's laddered marginTiers, and the top-of-book bidPx/askPx. Market buys are margin-checked at the ask and sells at the bid, so the stream sizes against that directional price (carried forward as a stable spread ratio applied to the live mid) and against the tiered initial-margin schedule — so the previewed max matches what the venue will accept for tiered or wide-spread assets like BTC. Pass maintenanceMarginRate explicitly to override the fetched value.
maxBuyUsd/maxSellUsd are a live estimate, not a guaranteeThe streamed max accounts for fees, leverage, tiered initial margin, the bid/ask spread, and existing positions, plus a small (~10 bps) safety buffer. Because mids and the spread move between preview and submission, the executable max at the venue can still drift by a few basis points. To place exactly the maximum without risking an insufficient balance rejection, submit with useMax: true (the server sizes to the live max atomically) or pass a small sizeTolerance so the server shrinks the order to fit rather than rejecting it.
let stream = try await arca.watchMaxOrderSize(options: MaxOrderSizeWatchOptions( objectId: exchangeId, market: "hl:0:BTC", side: .buy, leverage: 5, applicationFeeTenthsBps: 40, // 4.0 bps (tenths of a bps) szDecimals: 5 // maintenanceMarginRate: omit to auto-fetch (one extra REST call), or pass // explicitly (e.g. "0.01" for BTC) to skip the lookup))
for await data in stream.updates { print("max buy size", data.maxBuySize) print("mark", data.markPx)}
await stream.stop()The stream updates when mid prices change and when exchange state changes (orders, fills, leverage, deposits, funding payments). The activeAssetData property holds the latest computed value.
Low-Level WebSocket API
For advanced use cases, the arca.ws manager provides direct access to path-scoped watches and typed event streams.
Connect & Watch
// Connect and watch a pathawait arca.ws.connect()await arca.ws.watchPath("/")
// Iterate over all eventsfor await event in await arca.ws.events { print(event.type, event.entityId ?? "")}Typed Event Streams
// Operation eventsfor await (operation, event) in await arca.ws.operationEvents() { print(operation.type, operation.state)}
// Balance updatesfor await (entityId, event) in await arca.ws.balanceEvents() { print("Balance changed on", entityId)}
// Exchange state updatesfor await (state, event) in await arca.ws.exchangeEvents() { print(state.marginSummary.equity)}
// Mid price updatesfor await mids in await arca.ws.midsEvents() { print("BTC:", mids["hl:0:BTC"] ?? "n/a")}
// Exchange fill eventsfor await (fill, event) in await arca.ws.fillEvents() { print("Fill:", fill.market, fill.size, "@", fill.price)}Connection Status
for await status in await arca.ws.statusStream { switch status { case .connecting: print("Connecting...") case .connected: print("Connected") case .disconnected: print("Disconnected") }}Path Watch Management
// Watch specific paths (ref-counted)await arca.ws.watchPath("/users/alice")await arca.ws.unwatchPath("/users/alice")
// Mid price subscriptions (ref-counted)arca.ws.acquireMids(exchange: "sim-hl")arca.ws.releaseMids()
// Disconnectawait arca.ws.disconnect()Path watches use prefix matching — watching /users/alice delivers events for all objects under that path. Watch / to receive all realm events. Watches are ref-counted; the server unwatch only fires when the last watcher releases.
Error Handling
The SDK uses a single ArcaError enum with associated values. All SDK methods are async throws.
do { try await arca.transfer( path: "/op/transfer/fund-1", from: "/wallets/alice", to: "/wallets/bob", amount: "250.00" ).settled} catch let error as ArcaError { switch error { case .validation(let message, _): print("Invalid input:", message) case .notFound(_, let message, _): print("Not found:", message) case .conflict(_, let message, _): print("Conflict:", message) case .unauthorized(let message, _): print("Auth failed:", message) default: print("Error:", error.localizedDescription) }}Error Cases
| Case | HTTP Status | When |
|---|---|---|
.validation | 400 | Invalid input |
.unauthorized | 401 | Missing or expired token |
.forbidden | 403 | Insufficient permissions |
.notFound | 404 | Resource not found |
.conflict | 409 | Duplicate or invalid state |
.internalError | 500 | Server error |
.exchangeError | 502 | Exchange service error |
.networkError | — | Network failure |
.decodingError | — | Response parsing failure |
Logging & diagnostics
The SDK emits structured diagnostic records for network calls, WebSocket lifecycle events, watch streams, candle chart updates, and token refresh paths. Records flow to two destinations:
- Apple unified logging (
os.Logger) under subsystemio.arcaos.sdk. Works in Xcode and Console.app with no configuration. - A host-provided
ArcaLogHandlerfor forwarding records to Datadog, Sentry, Crashlytics, or a custom backend.
Configuration
Pass logLevel and an optional logHandler when constructing Arca. The default level is .warning — quiet in production, but enough to surface failures that were previously silent.
#if DEBUGlet arca = try Arca(token: jwt, logLevel: .debug)#elselet arca = try Arca(token: jwt, logLevel: .warning)#endifRecord shape
Records delivered to your handler carry a level, a category, the message, an optional underlying error, and structured metadata like path, market, httpMethod, statusCode, and errorId.
public enum ArcaLogLevel: Int, Comparable, Sendable { case debug, info, notice, warning, error}
public struct ArcaLogRecord: Sendable { public let level: ArcaLogLevel public let category: String // "network" | "websocket" | "watch" | "cdn" | "auth" | "candle" public let message: String public let error: Error? public let metadata: [String: String] public let timestamp: Date}
public protocol ArcaLogHandler: Sendable { func handle(_ record: ArcaLogRecord)}Xcode / Console.app
Records are written to the unified logging system under subsystem io.arcaos.sdk. In Console.app, filter by subsystem. From the terminal:
log stream --level debug --predicate 'subsystem == "io.arcaos.sdk"'Datadog adapter
import DatadogLogs
struct DatadogArcaLogger: ArcaLogHandler { let logger: DatadogLogger func handle(_ record: ArcaLogRecord) { var attrs: [String: Encodable] = record.metadata attrs["category"] = record.category switch record.level { case .error: logger.error(record.message, error: record.error, attributes: attrs) case .warning: logger.warn(record.message, error: record.error, attributes: attrs) case .notice, .info: logger.info(record.message, attributes: attrs) case .debug: logger.debug(record.message, attributes: attrs) } }}
let arca = try Arca( token: jwt, logLevel: .warning, logHandler: DatadogArcaLogger(logger: myDatadogLogger))Sentry adapter
import Sentry
struct SentryArcaLogger: ArcaLogHandler { func handle(_ record: ArcaLogRecord) { let breadcrumb = Breadcrumb(level: record.level.sentryLevel, category: "arca.\(record.category)") breadcrumb.message = record.message breadcrumb.data = record.metadata SentrySDK.addBreadcrumb(breadcrumb)
if record.level >= .error, let err = record.error { SentrySDK.capture(error: err) { scope in scope.setContext(value: record.metadata, key: "arca") } } }}
private extension ArcaLogLevel { var sentryLevel: SentryLevel { switch self { case .debug: return .debug case .info, .notice: return .info case .warning: return .warning case .error: return .error } }}Crashlytics adapter
import FirebaseCrashlytics
struct CrashlyticsArcaLogger: ArcaLogHandler { func handle(_ record: ArcaLogRecord) { let meta = record.metadata .map { "\($0)=\($1)" } .sorted() .joined(separator: " ") Crashlytics.crashlytics().log("[\(record.category)] \(record.message) \(meta)") if record.level >= .error, let err = record.error { Crashlytics.crashlytics().record(error: err) } }}Custom backend
Handlers run on a serial queue, so you don't have to synchronize. Forward records anywhere — an HTTP endpoint, a file, a third-party analytics service — by implementing the single handle(_:) method.
struct StderrArcaLogger: ArcaLogHandler { func handle(_ record: ArcaLogRecord) { let line = "[\(record.category)/\(record.level)] \(record.message)\n" FileHandle.standardError.write(Data(line.utf8)) }}Categories & levels
| Category | Covers |
|---|---|
network | REST requests, retries, response decoding |
websocket | Connect, disconnect, reconnect backoff, server errors, delivery gaps, stale detection |
auth | Token refresh attempts, 401 handling, proactive refresh failures |
watch | Initial snapshots, gap-recovery refetches, watch-stream errors that used to be swallowed |
candle | Candle chart initial load, retry loops, gap recovery, array-shrink guards |
cdn | Candle CDN chunk fetches and API fallbacks |
ArcaUI Module
The ArcaUI module provides SwiftUI components for common Arca interactions. It is distributed as a separate product in the same Swift package — add ArcaUI to your target dependencies alongside ArcaSDK.
Installation
// Package.swifttargets: [ .target(name: "MyApp", dependencies: ["ArcaSDK", "ArcaUI"]),]PaymentLinkView
A SwiftUI view that wraps SFSafariViewController to present payment link pages in-app. The end user completes the deposit or withdrawal flow inside the Safari browser sheet without leaving your app.
import ArcaUI
struct CheckoutView: View { @State private var showPayment = false let paymentURL: URL // from createPaymentLink().paymentLink.url
var body: some View { Button("Pay Now") { showPayment = true } .paymentLinkSheet( isPresented: $showPayment, url: paymentURL, onDismiss: { // Refresh state after payment } ) }}PaymentLinkView can also be used directly as a standalone view if you need more control over presentation:
PaymentLinkView(url: paymentURL) { // Called when the user dismisses the Safari view print("Payment flow dismissed")}Note: ArcaUI requires iOS (UIKit) and is not available on macOS.