Skip to content

feat(uta): add a cTrader Open API broker pack - #1784

Draft
mehdijamshidian90 wants to merge 22 commits into
TraderAlice:devfrom
mehdijamshidian90:feat/uta-broker-ctrader
Draft

mehdijamshidian90 wants to merge 22 commits into
TraderAlice:devfrom
mehdijamshidian90:feat/uta-broker-ctrader

Conversation

@mehdijamshidian90

Copy link
Copy Markdown

Summary

Adds a ctrader Broker Pack: a UTA broker for any broker that offers cTrader (FX, metals, indices, commodities, share and crypto CFDs), demo and live, through Spotware's cloud Open API. Nothing has to run on the user's machine and there is no third-party SDK: the adapter speaks the documented JSON protocol over the runtime's global WebSocket (Node 22+, Bun). It is broker- and symbol-neutral. Every instrument comes from the account's own symbol catalog, and nothing is hard-coded per broker, region or symbol.

Why cTrader: many retail brokers offer it, including in regions the current broker set does not serve. The Open API is free, cloud-hosted and has a demo environment, so it fits the Broker Pack boundary without native code (MT5, by comparison, needs a running Windows terminal).

This is a draft for your review. I have not opened a separate proposal issue. If it is easier to review in parts (for example transport and session first), I am happy to split it.

Design

Code lives in services/uta/src/domain/trading/brokers/ctrader/, wrapped by packages/uta-broker-ctrader like the other packs.

  • Transport (ctrader-transport.ts): request/reply correlation by clientMsgId, timeouts, heartbeat, and FIFO rate limiting below the documented per-connection limits (50 req/s, 5 req/s historical). Payloads are never logged; auth requests carry secrets.

  • Session (ctrader-session.ts): app auth, then account discovery by access token, then account auth. The account is selected by the login shown in cTrader, and a demo login on a live connection (or the reverse) is refused. The session never reconnects on its own. It reports dead/alive, and UTA's recovery loop calls an idempotent ensureConnected(). Concurrent callers share one in-flight attempt, so recovery can never run overlapping handshakes (the failure class behind [UTA] Recovery loop: a nudge during an in-flight attempt starts a parallel chain, and close() does not stop in-flight recovery #1762).

  • Identity: nativeKey = the broker's symbol name, conId = symbolId, secType CFD for every instrument, currency = quote asset. assetClassFor is derived from the broker's own category and asset-class names.

  • Units: quantities are base units, never lots, because lot size differs per broker and symbol. Volume errors name the lot size so an agent can convert.

  • Orders: MKT, LMT, STP and STP LMT.

    • DAY becomes GTD at the symbol's session close, since cTrader has no DAY.
    • Market-order SL/TP must be sent as distances from the fill, so the adapter re-applies them at the exact prices after the fill.
    • An amend re-sends the current entry price, protection and expiry, because the venue rejects or resets fields an amend omits. A stop-only amend of a stop-limit keeps its limit price where it is, as on IBKR.
    • A lost order reply is recovered by looking up a generated clientOrderId before reporting an unknown outcome.
  • Position protection: cTrader keeps SL/TP on the position, not as orders. They are exposed as synthetic orders sl-<positionId> / tp-<positionId>, which are:

    • returned in PlaceOrderResult.legs
    • listed by getOpenOrders
    • moved by modifyOrder and removed by cancelOrder

    An amend always re-sends the other level, because the venue drops an omitted one.

  • Hedged accounts: an order opposite an open position would open a second, opposite position. UTA's model is netting, so such an order is linked to the position it reduces (positionId). Orders that cannot be linked are refused with an explanation: a flip, an order spanning several positions, or one with both sides open. The venue does not cancel a linked pending order when its position closes, so closePosition does.

  • Reads: hedged positions are merged per (symbol, side) with a volume-weighted average cost. closePosition closes the oldest first and refuses when both sides are open.

Integration

  • CTRADER_PRESET in Recommended, with the securities guard set and demo/live modes. Client secret and tokens are write-only, and the fingerprint is mode + account number.
  • Engine union, registry, installable engines, supported platforms, build/verify scripts, dev binding, test lanes, release asset lists, UI engine type and ui/src/demo handlers.
  • pnpm-lock.yaml gains only the new importer.
  • Docs:
    • docs/broker-packs.md
    • cTrader venue facts and a round record in docs/uta-live-testing.md
    • plans/ctrader-broker-pack.md

Decision needed: rotating refresh token

cTrader access tokens last about 30 days. Refreshing works, but it rotates the refresh token and voids the stored pair. UTA cannot write Alice's sealed accounts.json. So today the rotated pair lives only in memory (the default hook logs a warning), and a UTA restart after a refresh needs a new authorization. The preset hint tells the user.

The plan lists three options:

  • (a) a UTA → Alice credential write-back that re-seals the account
  • (b) a UTA-owned sealed token store
  • (c) Alice owns the refresh lifecycle and hands UTA a current access token

I did not want to pick a cross-owner design without you. The session isolates the mechanics behind a single onTokensRefreshed hook, so any of the three plugs in there.

Verification

  • 176 cTrader specs against a fake socket, a fake server and a stateful fake venue that reproduces the venue behaviours listed below.
  • Typechecks: root, services/uta, @traderalice/uta-protocol, the pack, and ui.
  • pnpm test (and pnpm test:changed after the last fix) on Windows x64: 7,755 passed. The 14 failures in 11 files fail identically on clean dev 5da8dfc on this host (symlink EPERM without Developer Mode, and three CLI environment specs).
  • pnpm test:integration:uta: 291 passed.
  • pnpm broker-packs:build and verify-broker-packs.ts --compiled with the pinned Bun 1.4.2 on win32-x64: six archives verified; the cTrader pack is 2.4 MiB and imports and constructs in the compiled probe.
  • pnpm -F open-alice-ui dev:demo: the Connect-broker card, install step and form (six fields, secrets masked), with no console errors.
  • Spotware demo, hedged EUR account, 2026-10-13/14. Adapter-level probe scripts with a wire log that redacts auth fields. Each run refused to start unless the account was demo and flat, and ended with a flat check. Covered:
    • read surface (catalog, account, positions, orders, quotes, bars)
    • place/amend/cancel of a pending order
    • market order with SL/TP; partial and full close
    • moving and cancelling protection legs
    • a linked pending stop that triggered: it closed the long and opened no hedge
    • a pending stop-limit amended stop-only, volume-only and limit-only: the untouched prices and the GTD expiry stayed in place
  • Found on the demo and fixed along the way, each guarded by a spec:
    • an amend without the expiry silently turned GTD into GTC
    • AMEND_POSITION_SLTP drops an omitted level
    • linked pending orders outlive their position
    • an amend of a stop or stop-limit order without its stop price is rejected, even when the stop is unchanged
    • session edges were rounded with the current milliseconds
  • Connected through the real Connect-broker flow in a separate local OpenAlice instance. Account, positions and orders shown; the account recovered after UTA restarts.

Known limits

  • The agent-surface S1–S12 round through alice-uta has not been run yet; acceptance so far is adapter-level.
  • Partial fills were not observed on the demo.
  • First authorization means pasting tokens from the Open API portal's playground. There is no OAuth redirect flow in the UI.
  • Spot subscriptions stay open for every symbol quoted during a connection.
  • getMarketClock reports the first listed symbol's schedule, because cTrader has no venue-wide clock.
  • Run locally on Windows x64 only. Other platforms rely on CI.

…moves

cTrader stores a stop-limit's limit as a distance from the stop, so an
amend that sent only the new stop moved the limit with it. Recompute the
distance from the unchanged limit, refuse a stop that would cross it, and
re-send the current distance on every stop-limit amend.
…nects

Trading mode, schedule and volume limits were cached for the life of the
connection. Drop a spec when the venue reports the symbol changed, and drop
all of them after a reconnect, since changes may have been missed.
The venue rejects an amend of a stop or stop-limit order that omits the
stop price, even when the stop is unchanged ("Stop price should be
defined", Spotware demo), so volume-only and limit-only amends failed.
Re-send the current stop price, and a limit order's limit price, unless
the amend changes it. The fake venue now reproduces the rejection.
@vercel

vercel Bot commented Oct 13, 2026

Copy link
Copy Markdown

@mehdijamshidian90 is attempting to deploy a commit to the luokerenx4's Team Team on Vercel.

A member of the Team first needs to authorize it.

cTrader has no venue-wide clock and the market clock reports only the
first listed symbol's schedule, while contract details left
`tradingHours` empty. An agent therefore had no way to learn when a
given symbol trades (index CFDs, FX, metals and crypto all differ).

Contract details now carry the symbol's own schedule for the coming
week in IBKR's `tradingHours` format, wall-clock in the symbol's
`timeZoneId`, with days without a session marked CLOSED. Session
starts round up and ends round down to the minute so the hours never
claim the symbol trades when it does not, and the wall-clock
arithmetic keeps the hours stable across daylight-saving changes.
A symbol without a schedule still reports no hours.

Read back on the Spotware demo for XAUUSD, EURUSD, US 30, US 500,
US TECH 100, XTIUSD, BTCUSD and ETHUSD.
When a position's stop-loss or take-profit fired on the venue, the
adapter reported the leg as cancelled and never gave an exit price, so
UTA could not list the fill and journals had to guess the exit.

The venue serves both legs with one STOP_LOSS_TAKE_PROFIT order that
carries the stop-loss as `stopPrice` and the take-profit as
`limitPrice`; the settle logic read `stopLoss`/`takeProfit`, which that
order does not have. The order list by position also leaves the
protection order out. Settled legs now come from the position's deals:
the closing deal points to the order that closed it, a protection order
fires the leg whose level is nearer the fill, and the deals give the
volume-weighted exit price and volume. A position closed by another
order cancels both legs.

Orders read from the venue now also report `executedVolume` as their
filled quantity, so a pending order that fills later is listed as a fill
too.

Verified on the Spotware demo with a fired take-profit (minimum EURUSD,
account flat afterwards).
On the demo venue, the deal list for a just-closed position answered
INCORRECT_BOUNDARIES for one to two hours and then accepted the same
window. The error escaped getOrder, so UTA's order sync failed for every
order and pushes failed too: a break-even stop move stayed pending until
it went stale.

An unreadable closed leg is now reported as unknown, so UTA keeps
syncing the other orders and retries the leg on a later poll.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant