Skip to content

Create a Custom Broker

Use a custom broker to connect AQE to an execution venue. Your adapter owns transport, account observations and order execution. The strategy keeps ownership of its insights; the broker receives immutable order requests rather than the strategy’s full insight tree.

This guide follows the AQE 0.2.0 interfaces. Match your implementation to the traits in the AQE revision your strategy uses. Start against a mock transport or the venue’s sandbox, not a funded live account.

1. Implement connection and order management

Section titled “1. Implement connection and order management”

Create a Rust type implementing aq_engine::core::broker::traits::{Broker, OrderManagementProvider}. It must be Send + Sync and own its connection, caches and event queue.

TraitRequired methodsResponsibility
Brokerconnect, disconnect, is_connectedOwn the transport lifecycle and clean up even after a cancelled connection attempt.
Brokerget_name, get_account_typeIdentify the adapter and the account environment accurately.
OrderManagementProvidersubmit_order, cancel_orderSubmit a BrokerOrderRequest and cancel a broker order by ID.
OrderManagementProviderupdate_take_profit, update_stop_lossModify protection with the supplied price and quantity.
OrderManagementProviderclose_position, close_all_positionsRequest position closure and manage remaining protection.
OrderManagementProviderget_orders, get_orderReturn AQE Order records with real broker identities and statuses.
OrderManagementProviderget_positions, get_positionReturn Position records; the single-position method takes a symbol.
OrderManagementProviderget_accountReturn an Account with balances, currency, buying power and limits.
OrderManagementProviderdrain_trade_events_bounded, has_pending_trade_eventsExpose an ordered, non-blocking event queue.
OrderManagementProvidersubscribe_to_trade_stream, unsubscribe_from_trade_streamManage trade callbacks and their lifecycle.

Several methods explicitly require Send futures, including connect, individual order mutations, get_orders, get_positions and get_account. Follow the exact trait signatures; an async fn implementation is suitable when its future is Send. Never hold a non-Send guard across .await or perform blocking transport work on a runtime worker.

For a venue without push events, plan how polling produces lifecycle events and supports the required subscription contract. Do not advertise TradeEventStream unless the broker actually provides events without AQE polling.

Override Broker::get_features() with a static slice of BrokerFeature values. Advertise only features the adapter actually supports, such as MarketOrder, LimitOrder, PartialClose or BrokerManagedOco. Advertise AmendLimitPrice and AmendStopPrice separately when the venue supports those entry price changes on an unfilled order.

Report buying power consistently: BuyingPowerLeveraged means the returned buying power already includes leverage. Map supported ceilings to Account.max_total_orders and Asset.max_total_size; individual order size belongs in Asset.max_order_size. Do not invent broker limits or advertise authoritative estimates based only on generic notional arithmetic.

For an unsupported operation, return BrokerError::UnsupportedOperation before any transport mutation. Do not silently substitute another order type or return success.

2. Translate requests without losing identity

Section titled “2. Translate requests without losing identity”

submit_order receives a BrokerOrderRequest. Map its symbol, side, quantity, order type/class, limit and stop prices, protection levels and trailing stop to the venue’s supported representation.

Preserve client_order_id for correlation and use it as the venue’s client-order identifier when supported. It remains stable across retries or recovery of the same entry intent; it is not evidence that the venue created an order. Return the actual broker order ID in the resulting Order.

Keep these outcomes distinct:

OutcomeAdapter behaviour
Unsupported before sendingReturn UnsupportedOperation; no mutation occurred.
Proven refusal before the broker mutation callUse SubmissionRejected with the reason.
Transport timeout after sendingPreserve uncertainty. Do not claim rejection or blindly submit again.
Accepted requestReturn the broker’s observed order state; acceptance alone is not a fill.
Subsequent fill, rejection, cancellation or closeEmit lifecycle evidence with the corresponding order and event.

replace_order is optional and defaults to an unsupported error. Override it for pending-entry price/quantity replacement. It is not a substitute for update_take_profit or update_stop_loss.

amend_entry_prices(order_id, limit_price, stop_price) is a separate optional method for changing the limit component or stop trigger of a pending entry without changing its quantity. Its default returns an unsupported error. Implement it before advertising either amendment capability. Do not route a stop-trigger amendment to update_stop_loss, which controls protective stops.

Populate account, order and position records

Section titled “Populate account, order and position records”

Rust struct literals must include every field in the current type, including optional fields. #[serde(default)] helps read older serialised records; it does not make a field optional in a Rust constructor.

Record and fieldsAdapter requirement
Account.account_id, currency, accrued_commission, max_total_ordersKeep observations scoped to the actual account and currency. Populate accrued commission from broker evidence and the pending-order ceiling when reported. Legacy defaults are not evidence that costs or limits are absent.
Position.order_idNew optional exact execution/position ticket associated with the entry. Use Some(...) only when the adapter can prove the association; use None for legacy or symbol-aggregate records with multiple owners. Never invent an identity from the symbol.
Position.entry_commissionPreserve the position’s entry commission independently of open trading P&L. Do not count the same charge again when reporting final accounting.
Order.filled_qty, realized_pnl, commission, swapUse cumulative entry fills for an entry snapshot, but only the quantity executed by that exit for an exit event. Preserve broker-reported execution and cash values; acceptance is not a fill.
Order.position_accounting, OrderLifecycleCloseDetails.position_accountingOptional PositionCloseAccounting evidence for a provably owned position. Keep None when unsupported or ambiguous; it is not required simply to implement order submission.
BrokerLifecycleSnapshot.snapshot_complete, captured_atMark completeness only after enumerating the full relevant lifecycle view, and retain its observation time. An empty incomplete snapshot cannot prove that an order or position has disappeared.

When you construct an Order, retain its insight and strategy identifiers as well as the real broker order ID. Return the current Asset metadata with orders and positions, including its trading rules, calendar and fees where known. See custom datafeed metadata for the instrument fields.

get_position(symbol) can be an aggregate view. The newer get_position_by_order_id(order_id, symbol) performs an asynchronous exact lookup and returns Result<Option<Position>, BrokerError>. Its default checks get_positions() and rejects ambiguous or missing order identity rather than selecting an arbitrary position on the same symbol. Override it if the venue needs a dedicated ticket lookup.

For an exact in-memory simulation or custom cache, position_snapshot_reader can return a PositionSnapshotReader: an owned, thread-safe callback taking the order ID and symbol. Keep it transport-free. Return Ok(None) only for an observed absence; return an error when the data is unavailable or cannot establish the requested identity. Live strategy decisions use AQE’s timestamped snapshot cache, not an extra network request from an insight callback.

Maintain a queue of (Order, TradeUpdateEvent) in source order. drain_trade_events_bounded(max_events) removes at most that many events and leaves the rest in the queue. A limit of zero removes nothing. Never drain everything and then truncate the returned vector.

This helper illustrates the queue operation without prescribing a locking library. Call it while holding a short-lived guard around your adapter’s queue:

use aq_engine::core::broker::types::{Order, TradeUpdateEvent};
use std::collections::VecDeque;
pub fn take_trade_events(
queue: &mut VecDeque<(Order, TradeUpdateEvent)>,
max_events: usize,
) -> Vec<(Order, TradeUpdateEvent)> {
let count = max_events.min(queue.len());
queue.drain(..count).collect()
}

has_pending_trade_events checks queue readiness without network or database I/O. Invoke callbacks outside locks and keep them short. Publish partial fills and protective-leg updates accurately, and normalise duplicate or out-of-order vendor messages rather than inventing terminal states.

The default broker-neutral event wrappers generate event IDs with unknown source timing. Override subscribe_to_broker_event_stream and related envelope handling when your adapter can supply reliable BrokerOrderEvent identities, sequence numbers and timestamps.

close_position owns protection cleanup: a full close cancels remaining native legs; a partial close preserves or resizes protection for the remaining exposure. Ok(true) acknowledges the command, not a confirmed close. Emit terminal trade evidence separately.

Optional hooks improve supervision and recovery:

HookWhen to implement it
get_cached_accountReturn the newest account snapshot without transport I/O.
get_cached_positionsSupply transport-free positions for deterministic in-memory execution, not a replacement for timestamped live refreshes.
get_cached_lifecycle_snapshotSupply a complete authoritative lifecycle view without I/O; return None until one exists.
estimate_order_marginObtain an authoritative estimate for the exact request, without placing it.
reconcile_submissionLook up the exact account and client command without resubmitting it.
reconcile_order_lifecycleResolve order IDs using order history and position identity after restart.
lifecycle_reconcilerProvide an owned Arc<dyn OrderLifecycleReconciler> for restart recovery.
get_position_by_order_id, position_snapshot_readerResolve the exact entry ticket, with asynchronous refresh and transport-free lookup kept separate.
pending_cancellation_recovery, retry_cancellationSupport evidence-based recovery of an uncertain cancellation through the durable command worker.
execution_fill_history_readerSupply bounded, read-only fill history for an exact account, entry order and symbol.
position_valuation_readerSupply read-only account-currency marks for exact physical positions, not a symbol-wide estimate.
account_cash_flow_readerSupply read-only account funding and adjustment records with source identity, sequence and coverage.
configure_live_session, configure_asset_metadataAccept session context and asset rules when the adapter needs them.

The default submission resolver returns Unsupported. The default lifecycle resolver uses get_orders() and reports missing IDs as Unknown; it cannot establish that a filled order’s position has closed. Never interpret an absent order, an incomplete snapshot or a timeout as proof of cancellation or closure.

Recover cancellations without replaying orders

Section titled “Recover cancellations without replaying orders”

pending_cancellation_recovery(account, order) is read-only evidence that an uncertain cancellation still targets a pending order. Its default returns None, which is not proof of a terminal state. Only the durable command worker may call retry_cancellation after recording a recovery attempt. That hook must not submit, close or replace an order; it must obtain authoritative cancellation evidence and deliver it through the normal broker event reducer. The default is unsupported. Do not add an independent retry timer that bypasses this process.

The history, valuation and cash-flow reader hooks return None by default. An execution adapter can retain those defaults when it cannot provide the evidence. Unsupported analytics must remain unavailable, not fabricated empty history, a flat portfolio or zero cash flow.

  • ExecutionFillHistoryReader::read(account_id, entry_order_id, symbol) returns an ExecutionFillHistory for a provably single-entry-order position. Preserve fill identities and source timing; position_snapshot can add an independently observed quantity check. A missing or netted history is not an empty proof.
  • PositionValuationReader::read_positions(account_id, symbol, position_ids) returns PositionValuations for a bounded exact-position batch. Preserve account currency, as_of_us, quote timing, trading P&L, carry and an explicit notional basis or unavailable reason. NotOpen is an observation of absence, not a closing fill or permission to close an insight.
  • AccountCashFlowReader::read(account_id, source_id, after_sequence) returns an AccountCashFlowPage with source identity, currency, time coverage, sequence cursor and records. Record actual funding or adjustments; do not infer deposits or withdrawals from equity changes, fills or insight state.

All three return an optional result inside Result. An unavailable read and an error are different from an authoritative complete observation with no matching records. They must never place orders or perform lifecycle reconciliation as a side effect of reading analytics.

UnifiedBroker accepts your execution type directly. This helper works with any compliant broker and datafeed; both adapters must already implement their respective traits:

use aq_engine::core::broker::{
traits::{Broker, DataFeed, DataProvider, OrderManagementProvider},
UnifiedBroker,
};
pub fn live_with<E, D>(execution: E, datafeed: D) -> UnifiedBroker<E, D>
where
E: Broker + OrderManagementProvider,
D: DataFeed + DataProvider,
{
UnifiedBroker::new(execution, datafeed)
}

Use PaperBroker with UnifiedBroker::new_backtest for the built-in historical simulator. Implementing the live broker traits alone does not turn a remote execution venue into that simulator. AQS connection-picker support is a separate desktop integration, not an automatic consequence of implementing the traits.

  1. Exercise connection failure, cancellation, reconnect and complete shutdown.
  2. Test each advertised order type, precision rule and capacity limit.
  3. Verify partial fills, cancellation, protective updates, partial closes and full-close cleanup.
  4. Test bounded event drains with zero, one and several events; assert that order and remaining events are preserved.
  5. Simulate a submission timeout after venue acceptance. Confirm recovery does not duplicate the order.
  6. Restart with pending orders and open positions; verify unknown and confirmed outcomes remain distinct.
  7. Compare account, order and position observations against the venue’s sandbox records before considering live use.
  8. Keep two entries on the same symbol separate. Test exact ticket lookup, unknown identity and incomplete snapshots rather than selecting the first matching symbol.
  9. Simulate an uncertain cancellation across a restart, and verify that only the durable recovery path may retry it.
  10. Test optional readers with unsupported, incomplete, stale and foreign-account data. Confirm that analytics reads cannot submit orders or manufacture accounting evidence.

In your AQE checkout, start with:

  • src/core/broker/mod.rs: execution traits, defaults and composition.
  • src/core/broker/commands.rs: BrokerOrderRequest and SubmissionResolution.
  • src/core/broker/types/mod.rs: account, order, position, lifecycle and error types.
  • src/core/broker/features.rs: native capability flags.
  • src/core/broker/fill_history.rs, position_valuation.rs and account_cash_flow.rs: optional read-only analytics contracts.
  • live-sync-protocol/src/position_accounting.rs and position_valuation.rs: accounting evidence and valuation validation.
  • src/core/broker/mt5_broker.rs and src/core/broker/paper_broker.rs: live and simulated implementations.

Next: Create a custom datafeed or Live Runtime.