Live Multi-Broker Copy Engine
How real trades on a real master broker account get copied, live, onto a real follower broker account — potentially on a completely different platform. Read this alongside docs/ARCHITECTURE.md (the Copy Engine and Broker Abstraction Layer sections) and docs/BROKER_INTEGRATION_REPORT.md (what each of the 7 connectors actually supports).
What already existed vs. what this milestone added
This codebase's Copy Engine (src/lib/copy-engine/) was already a complete, independently-tested risk/filter/lot-sizing/session-management engine — every lot-sizing mode (fixed lot, multiplier, % equity, balance ratio, equity scaling), every filter (symbol, direction, spread, slippage, pending-order, time-of-day/session, news, max open trades, max position size, max drawdown, daily loss limit), pause/resume/emergency stop, retry with backoff, idempotency, and reconnect reconciliation (PositionSynchronizationService) were all real and tested before this milestone touched anything. So was the connector layer's own side of the bridge: every connector normalizes its broker-specific events to one StandardTrade shape, and toTradeSignal() (src/lib/connectors/normalizer/to-trade-signal.ts) was already the one, already-tested seam turning that into what the Copy Engine consumes.
What was missing, confirmed by reading `CopyPersistencePort` (`src/lib/copy-engine/persistence.ts`) and its production implementation (`supabase-persistence.ts`): the Copy Engine's replication decisions only ever wrote database rows. openFollowerTrade/closeFollowerTrade/ modifyFollowerTrade/partialCloseFollowerTrade recorded what SHOULD happen on the follower's account — the exact same code path /dashboard/copier's "Simulate a Trade" feature uses — but never called a live TradingConnector.placeOrder()/modifyOrder()/closeOrder() on a real broker. ConnectorManager's own doc comment, PositionSynchronizationService's LiveMasterPosition doc comment, and CopySessionManager's own doc comment all independently said the same thing: a persistent worker process to actually hold connections open and execute real orders was "a deployment decision outside this milestone." This milestone is that worker, plus the execution bridge it needs.
Architecture
Real broker (master) Real broker (follower)
│ TradingConnector.streamTrades() ▲
▼ │ TradingConnector.placeOrder()/
ConnectorManager.startStreaming() │ modifyOrder()/closeOrder()
│ bus.publish('trade', StandardTrade) │
▼ │
EventBus (src/lib/connectors/event-bus) │
│ │
▼ │
toTradeSignal() (src/lib/connectors/normalizer — unchanged) │
│ TradeSignal │
▼ │
CopyEngine.submit() (src/lib/copy-engine — unchanged) │
│ risk checks, filters, lot sizing (all unchanged) │
▼ │
LiveExecutionCopyPersistence (src/lib/live-copy — NEW) ───────┘
│ delegates the DB write to
▼
SupabaseCopyPersistence (unchanged)LiveExecutionCopyPersistence (src/lib/live-copy/live-execution-persistence.ts) is a CopyPersistencePort decorator — it wraps SupabaseCopyPersistence and, for the four follower-mutating methods, calls the follower's real connector first, then delegates to the wrapped port for the DB write (now including the real order's id). It lives outside src/lib/copy-engine specifically because the Copy Engine must never import anything under src/lib/connectors (see symbol-mapping-service.ts's own doc comment on that boundary) — this is the one file in the whole codebase allowed to sit on both sides of it at once, by design. Zero Copy Engine business logic changed — risk checks, filters, lot math, and event ordering are byte-for-byte the same code they were before this milestone; only the persistence port underneath them got smarter.
CopyWorker (src/lib/live-copy/copy-worker.ts) is the actual persistent process: it discovers every trading account that's a master or follower of an active copy_relationship, opens (and holds open) a real connector for each via ConnectorManager (unchanged), wires their streams onto one EventBus (unchanged), and feeds every event through toTradeSignal() → CopyEngine.submit() with a LiveExecutionCopyPersistence-backed CopyEngine. It also periodically re-hydrates session state (so a dashboard pause/resume/new relationship takes effect within one sync interval) and reconciles every connected master's real live positions via TradingConnector.getPositions() — CopyEngine.reconcile()'s already-existing reconnect-recovery logic, just fed real data for the first time.
Symbol translation (Phase 3)
Two systems already existed for this, serving different purposes:
SymbolMappingService(src/lib/copy-engine/symbol-mapping-service.ts)
— a manual, per-relationship override (copy_relationships.symbol_mapping), already wired into TradeReplicationService.replicateOpen before this milestone. Always wins when configured.
AssetTranslator(src/lib/connectors/asset-translator/) — an
automatic canonical-symbol translator (explicit per-broker mappings → a curated alias table → a suffix-stripping heuristic), fully built but never wired into a real execution path before this milestone.
LiveExecutionCopyPersistence.openFollowerTrade now tries the manually- mapped (or unchanged) symbol first; only if the follower's real connector rejects the order with a recognizably symbol-related reason does it retry once via AssetTranslator.translate(). Manual mapping always takes precedence — the automatic fallback only ever activates for symbols the user never explicitly configured, which is the correct precedence: an explicit override should never be silently second-guessed by a heuristic. The alias table itself remains the small, illustrative starter set it always was (EURUSD/GOLD/US30/WTI/BTC families) — extending it to more symbols is a data change to symbol-aliases.ts, not an architecture change.
Retry-safety (no duplicate real orders)
CopyEngine retries a failed replicate() call (copy-engine.ts's withRetry). If a previous attempt already placed a real order but failed before/during its own DB write, a naive retry would place a SECOND real order. openFollowerTrade guards against this by checking findOpenFollowerTrade(relationshipId, masterTradeId) first — if a follower trade already exists for this exact master trade, the retry returns it unchanged, with no new real order and no new DB row.
Broker execution matrix
Every connector implements the identical TradingConnector interface and the bridge above never branches on which one it's talking to — so every platform below is capable of being BOTH a master (source) and a follower (destination) for every other platform, with no platform-specific code anywhere in the pipeline. Proven directly (not just asserted) by tests/live-copy-cross-broker-integration.test.ts, which runs a real TradeLocker → Match-Trader open-then-close sequence through the exact same wiring CopyWorker uses — the same test, with two different connectors substituted in, is the proof for every other pair.
| Platform | Can be source | Can be destination | Caveat |
|---|---|---|---|
| MT5 | ✅ | ✅ | Requires METAAPI_TOKEN |
| TradeLocker | ✅ | ✅ | |
| Tradovate | ✅ | ✅ | |
| DXtrade | ✅ | ✅ | |
| cTrader | ✅ | ✅ | Requires a pre-obtained OAuth accessToken (see docs/BROKER_INTEGRATION_REPORT.md) |
| Match-Trader | ✅ | ✅ | |
| Rithmic | ⚠️ architecturally yes, functionally no | ⚠️ architecturally yes, functionally no | Its wire transport is unimplemented (docs/RITHMIC_TRANSPORT.md) — ConnectorManager.connect() throws for any Rithmic account today; the moment a real RithmicTransport exists, Rithmic works here with zero changes to this milestone's code |
That's 6 fully functional platforms today — 30 functional directed pairs (6 × 5, excluding same-platform-to-itself, which also already works) — plus Rithmic as a 7th the instant its transport is filled in.
Deployment
CopyWorker is not part of the Next.js web app's request/response cycle and must never be imported from a Server Action or page — it holds real, stateful, long-lived connections (open sockets, poll loops), which ConnectorManager's own doc comment already explains is the wrong thing for a serverless request handler to own.
Run it as its own process:
npm run copy-worker
# or directly:
npx tsx scripts/run-copy-worker.tsIt needs the same environment variables as the Next.js app (NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, CREDENTIAL_ENCRYPTION_KEY, METAAPI_TOKEN if any connected account is MT5) — npm run copy-worker passes Node's own --env-file=.env.local so the script needs no dotenv dependency of its own. In production, run it as a second long-lived process/container alongside (not instead of) the Next.js deployment — a systemd service, a PM2 process, or a second container in the same Docker Compose/Kubernetes deployment as the web app, restarted on crash like any other long-running service. It uses the Supabase service-role key (bypassing RLS) because a background worker services every user's active relationships at once, not one signed-in request's own accounts — see src/lib/supabase/service-role.ts's own doc comment for the other two established uses of that same client.
Known limitations — stated plainly, not hidden
- Pending-order type is simplified.
CopyPersistencePort.openFollowerTrade
carries a status: 'open' | 'pending' flag but no LIMIT-vs-STOP distinction for the pending case (this predates this milestone). A copied pending order is always placed as LIMIT at the master's signal price — correct for the common case, wrong order type for a copied STOP order specifically. Fixing this means threading the master's order type further through TradeSignal/CopyPersistencePort, not attempted in this milestone.
- Partial close is DB-only, not truly live. No connector's
closeOrder() accepts a partial volume — every connector's own closeOrder targets the whole position by id (see each connector's own order-service.ts). LiveExecutionCopyPersistence.partialCloseFollowerTrade therefore records the correct DB-side bookkeeping (so the dashboard and Copy Engine's own statistics stay accurate) without placing a matching real partial-close order. A real fix needs a TradingConnector.closeOrder(accountId, orderId, volume?) signature change across all 7 connectors — a larger, separate piece of work.
- Trailing stop updates copy generically, like any other SL change —
there's no distinct "trailing stop" concept anywhere in this codebase's connector or Copy Engine types; a trailing-stop-driven SL move arrives as an ordinary modify event and is mirrored through the same generic SL/TP path every other SL/TP change already uses. This was already true before this milestone (see docs/BROKER_INTEGRATION_REPORT.md's Architecture Notes) and remains the correct, intentional design — not a gap.
- **Contract-size-aware volume normalization across asset classes is
not implemented.** computeLotSize/clampToMaxPositionSize (src/lib/copy-engine/lot-scaling.ts) already state this limitation plainly: real equivalent position sizing needs each instrument's contract size and pip value, which isn't available without a live market data feed. This milestone didn't add one.
- **Maximum Exposure (across all open positions) is not a distinct
check.** Only Maximum Position Size (per trade) and Maximum Open Trades (a count) exist. A true exposure check needs summed mark-to-market value across every open position, which needs live pricing this codebase doesn't have a feed for.
- **Magic-number / prop-firm-specific filters beyond what already
existed are not added.** StandardTrade carries no magic-number field (MT5's own concept; not modeled by any connector's normalizer). Prop-firm-specific rules already added in an earlier milestone (Risk Profiles: max open trades, position size, time/session filters, news filter) apply here unchanged.
- **No live-broker or production-database performance validation was
performed, and could not be, in this environment — there is still no live broker connectivity and no production-scale infrastructure available here. Updated by the Beta Launch readiness pass**: local, synthetic load testing of CopyEngine.submit() itself (the exact class CopyWorker's hot path calls into) at 100-10,000 simultaneous users now exists — see docs/STRESS_TEST_REPORT.md for the full numbers, methodology, and — just as importantly — exactly what it still doesn't cover (real DB latency, real broker latency, multi-process behavior). Headline finding: single-process throughput is flat and low-latency up to roughly 1,000-2,000 simultaneous events, then degrades (a single Node process is single-threaded, not a queue-depth problem) — giving the "500+ simultaneous copier relationships" claim below a concrete number to plan horizontal sharding around, in place of the prior unbenchmarked assumption. CopyWorker's hot path (EventBus dispatch → toTradeSignal → CopyEngine.submit → LiveExecutionCopyPersistence) does no artificial delays, uses in-memory Maps for connection/session lookups, and reuses the exact reliability primitives (retry, idempotency, queue) the new report measures directly — but claims like "10,000+ copied trades/day" still describe sustained daily volume, not simultaneous burst size, and remain architecture- consistent rather than measured for that specific claim.
- No new real-time push UI was built. The existing dashboard
(/dashboard/copier/*, broker-status-panel.tsx, connection-diagnostics.tsx) already reads the same trades/ positions/copy_logs/copy_relationships tables this worker writes to, so real executions appear there automatically on the page's own existing refresh/poll cadence — but there's no WebSocket/ SSE channel pushing worker events straight to an open browser tab live; a page still has to be refreshed or already have its own polling to see the newest state.
- **Session-state sync between the dashboard and the worker is
poll-based, not instant.** A dashboard pause/resume/emergency-stop writes to copy_relationships.status immediately, but a running CopyWorker only re-hydrates its in-memory session cache once per syncIntervalMs (default 10s) — see copy-worker.ts's own doc comment for why this is still correct (never permits something the database disallows, only takes up to one interval to newly permit something).
- Not live-tested against a real broker.
CopyWorkerwas verified
to start, connect to this project's real Supabase instance, and run its sync loop without error (no active copy_relationships exist in this environment's database, so no live broker connection was actually exercised). Every piece of new logic (LiveExecutionCopyPersistence, the cross-broker pipeline) is tested against fakes — the same "verified against fakes, not a live broker" standard every connector in this codebase is already held to (see docs/BROKER_INTEGRATION_REPORT.md's own "Live-tested vs. simulated" section).