Architecture
Overall system architecture
Wedge Trade AI is a Next.js 16 (App Router) application backed by Supabase (Postgres + Auth + Realtime). There is no separate backend service — every piece of server-side logic runs as a Next.js Server Action, Route Handler, or Server Component, scoped to one HTTP request.
flowchart LR
subgraph Browser
UI[Dashboard pages<br/>Server + Client Components]
end
subgraph "Next.js server (one request at a time)"
SA[Server Actions]
RH["Route Handlers (/api/health/*)"]
CE[Copy Engine]
CN[Broker Connectors]
end
DB[(Supabase Postgres<br/>+ Auth + Realtime)]
Brokers[(External broker APIs<br/>MT5 / TradeLocker / Tradovate)]
UI -- form actions / fetch --> SA
UI -- polls --> RH
SA --> CE
SA --> CN
CE -- CopyPersistencePort --> DB
CN -- REST / WebSocket --> Brokers
UI -- Supabase Realtime subscription --> DBTwo things follow from "everything is request-scoped" that shape a lot of design decisions elsewhere in this document:
- No persistent connector or engine instance exists across requests.
ConnectorManager, CopyEngine, and every connector are constructed fresh, used, and (for connectors) disconnected within one request. This is explicit and documented at each of those classes' own definitions, not an oversight — see "What this deployment model does and doesn't give you" below.
- Postgres is the only durable state. In-memory state (queue
contents, session pause/resume flags, latency histograms, metrics counters) all live only as long as one server process does, and is reconstructed from the database (copy_relationships.status, broker_connections, copy_logs, …) the next time it's needed.
What this deployment model does and doesn't give you
Does: every dashboard action (test a connection, place a simulated trade, pause a copy session, check broker health) works correctly and completes within its own request, because the whole pipeline it touches — connector, Copy Engine, database — is stood up and torn down together.
Doesn't: a broker pushing a live trade event with literally no user present to trigger a request. Real-time, always-on copying (master fires a trade at 3am, follower gets it seconds later with nobody looking at the dashboard) requires a long-running worker process that holds a ConnectorManager + CopyEngine alive and lets the connector's streamTrades()/streamOrders()/streamBalance() push directly into CopyEngine.submit(). That worker is a deployment decision, not a missing code path — every piece it would need (ConnectorManager, CopyEngine.submit(), the Event Bus) already exists and is already tested; it's simply never been given a process to live in continuously.
Broker abstraction layer
Every broker satisfies one interface, TradingConnector (src/lib/connectors/sdk/connector.ts), so nothing above the connector layer — the Copy Engine, the dashboard — ever imports or type-checks against a concrete Mt5Connector/TradeLockerConnector/ TradovateConnector.
classDiagram
class TradingConnector {
<<interface>>
+connect(credentials)
+disconnect()
+getAccounts()
+getBalance(accountId)
+getPositions(accountId)
+placeOrder(accountId, order)
+modifyOrder(accountId, order)
+closeOrder(accountId, orderId)
+streamTrades(accountId, cb)
+streamOrders(accountId, cb)
+streamBalance(accountId, cb)
+healthCheck()
}
class BaseConnector {
<<abstract>>
#state
+getConnectionUptimeSeconds()
#recordOrderMetrics(op, fn)
}
class Mt5Connector
class TradeLockerConnector
class TradovateConnector
class StubConnector
TradingConnector <|.. BaseConnector
BaseConnector <|-- Mt5Connector
BaseConnector <|-- TradeLockerConnector
BaseConnector <|-- TradovateConnector
BaseConnector <|-- StubConnectorBaseConnector implements nothing broker-specific — it owns connection- state tracking (as a state accessor, not a plain field, specifically so metrics can hook every state transition once for every connector — see Retry and Reconnect Strategy below) and a recordOrderMetrics() helper every connector's placeOrder/modifyOrder/closeOrder wraps its call in. Every actual behavior is abstract.
The four-service pattern
TradeLocker and Tradovate (both built after MT5, both REST/WebSocket-based rather than a bundled native SDK like MT5's MetaApi) are each decomposed into four focused, independently unit-tested services, composed by one connector class:
flowchart TB
Connector["XConnector<br/>(implements TradingConnector)"]
Connection[XConnectionService<br/>auth + session lifecycle]
MarketData[XMarketDataService<br/>instrument cache + live prices]
Position[XPositionService<br/>poll loop -> diff -> StandardTrade]
Order[XOrderService<br/>place / modify / cancel / close]
Connector --> Connection
Connector --> MarketData
Connector --> Position
Connector --> Order
Order --> MarketData
Position --> MarketDataEach service depends only on a small port interface (TradeLockerSession/TradovateSession, TradeLockerApi/TradovateApi, TradovateQuoteSocket) — never on the real network. The one file per connector that touches the network is sdk-adapter.ts, deliberately excluded from that connector's public barrel export and from every test; everything else is tested against a hand-written fake implementing the same port interface (see tests/helpers/*-fakes.ts).
Registry, Manager, Event Bus
- `PlatformRegistry` (
src/lib/connectors/registry/) maps a platform
id to a connector factory. Every platform starts wired to a StubConnector (whose action methods all throw ConnectorNotImplementedError); registerMt5Connector() / registerTradeLockerConnector() / registerTradovateConnector() swap in the real factory, called lazily and idempotently wherever live connectivity is actually needed (never at module load, so importing the connectors barrel never pulls in metaapi.cloud-sdk or makes a network call by itself).
- `ConnectorManager` (aliased
BrokerManager,
src/lib/connectors/manager/) owns live connector instances by an opaque connectionId, and wires a connected connector's three streams onto the Event Bus. Fully DB-agnostic — it has no idea what a broker_connections row is.
- `EventBus` (
src/lib/connectors/event-bus/) is an in-process typed
pub/sub carrying StandardTrade/balance/health/error events, published by connectors and (in a persistent-worker deployment) subscribed to by whatever bridges into the Copy Engine.
- `src/lib/connectors/shared/` holds everything genuinely reusable
across brokers so a third/fourth connector never re-derives it: ReconnectScheduler, HeartbeatMonitor, PollLoop, withLatency (reconnect/reliability primitives), fetchJson (retrying HTTP), and createConsoleLogger/silentLogger (now backed by the structured logger — see Observability below).
Normalization
Every connector's raw broker-specific events (an MT5 deal, a TradeLocker snapshot diff, a Tradovate netPos change) are translated into exactly one shape, StandardTrade (src/lib/connectors/sdk/trade.ts) — open/ close/modify/partial_close, nothing platform-specific. This is what lets the Copy Engine be broker-agnostic: it never sees an MT5 deal or a Tradovate position row, only a StandardTrade (via TradeSignal, see below).
Copy Engine architecture
src/lib/copy-engine/ never imports anything from src/lib/connectors — enforced by convention (checked by code review, not a lint rule) so the Copy Engine stays genuinely broker-agnostic rather than "broker-agnostic in principle." Two bridges connect the two systems, both living OUTSIDE src/lib/copy-engine for exactly that reason: src/lib/connectors/normalizer/to-trade-signal.ts, which turns a StandardTrade into a TradeSignal (resolving the platform's own account id to this app's internal trading_accounts.id, and a broker's externalTradeId to this app's internal trades.id for non-open events) — a pure function living in the connectors module; and src/lib/live-copy/live-execution-persistence.ts, a CopyPersistencePort decorator that turns a replication DECISION into a real order on the follower's real connector before delegating to the database write — see docs/LIVE_COPY_ENGINE.md for the full live, multi-broker execution architecture (the persistent worker process, symbol translation, retry-safety, and the broker execution matrix).
flowchart TB
Submit["CopyEngine.submit(signal)"] --> Queue[TradeEventQueue<br/>per-master FIFO lane]
Queue --> Process[CopyEngine.processEvent]
Process --> Persist1[persistence.recordMasterTrade]
Process --> Relationships[persistence.listActiveRelationshipsForMaster]
Relationships --> Loop{for each relationship}
Loop -->|session paused/stopped| Skip1[silently skipped]
Loop -->|session active| Filters[RiskCalculationService.checkTradeFilters]
Filters -->|blocked| Skip2[logged as 'skipped']
Filters -->|allowed| Delay[optional trade_delay_ms sleep]
Delay --> Replicate[TradeReplicationService.replicate]
Replicate --> Persistence2[(CopyPersistencePort -> Postgres)]
Replicate --> Stats[CopyStatisticsService]
Replicate --> Latency[LatencyMonitor + global metrics]
Replicate --> Bus["TradeEventBus.publish('copy-result')"]The nine services
| Service | Responsibility |
|---|---|
CopyEngine | Orchestrator — the only class every other piece is composed *by*, and the one public entry point (submit) |
CopySessionManager | Pause / Resume / Emergency Stop All — fast in-memory session state, persisted through a port |
RiskCalculationService | Pure lot-sizing math (5 modes) + account-risk + trade-filter checks — no I/O |
SymbolMappingService | Per-relationship master-symbol → follower-symbol override |
PositionSynchronizationService | Reconnect recovery — diffs live broker positions against recorded trades by external_trade_id |
LatencyMonitor | In-process copy-latency percentiles (p50/p95/p99) |
CopyStatisticsService | In-process live counters + a DB-backed historical query function |
TradeReplicationService | Executes one relationship's copy of one signal (open/close/modify/partial_close) |
TradeEventBus | In-process pub/sub for Copy Engine domain events (copy-result, duplicate, session-state-changed, emergency-stop, reconciled, error) |
CopyPersistencePort (src/lib/copy-engine/persistence.ts) is the one seam that touches a database; SupabaseCopyPersistence is the production implementation. Every service above is tested against an in-memory fake implementing that same port (tests/helpers/copy-engine-fakes.ts) — none of the Copy Engine's own tests touch Supabase.
Event flow, end to end
sequenceDiagram
participant Broker as Broker (via connector)
participant Normalizer as toTradeSignal()
participant Engine as CopyEngine
participant Queue as TradeEventQueue
participant Replication as TradeReplicationService
participant DB as Postgres
Broker->>Normalizer: StandardTrade (open/close/modify/partial_close)
Normalizer->>Engine: TradeSignal (internal account/trade ids resolved)
Engine->>Queue: enqueue(signal)
Queue->>Queue: idempotency check (dedupe by eventId)
Queue->>Engine: processEvent(event) [per-master FIFO]
Engine->>DB: recordMasterTrade(signal)
Engine->>DB: listActiveRelationshipsForMaster
loop each active, unpaused relationship
Engine->>Engine: checkTradeFilters (direction/spread/slippage/pending)
Engine->>Replication: replicate(signal, relationship)
Replication->>DB: open/close/modify/partialClose follower trade
Replication->>DB: logCopyAttempt (copy_logs row)
Replication-->>Engine: CopyResult
end
Engine->>DB: (via logCopyAttempt) copy_relationships.last_event_at/last_errorToday, the "Broker → Normalizer" step is driven manually by the Simulate-a-Trade form (/dashboard/copier) rather than a live streaming connector, because no persistent worker process holds a connector's stream open between requests (see "What this deployment model does and doesn't give you" above) — the toTradeSignal bridge and every step after it are the real, production code path regardless of what triggers it.
Database schema overview
All tables live in the public schema, RLS-protected per-user (auth.uid() = user_id on every user-owned table). Full detail is in supabase/migrations/; the table below is the shape, not the exhaustive column list.
| Table | Purpose |
|---|---|
profiles | Extends auth.users with app-specific fields (role, …) |
prop_firms, brokers | Shared reference catalogs |
broker_connections | One user's connection to one broker — encrypted_credentials, status |
trading_accounts | One account under a broker connection — balance, equity, server |
master_accounts, follower_accounts | Marks a trading account as a copy source/destination (1:1 with trading_accounts) |
copy_relationships | The "follower copies master" link — risk mode/value, symbol filter/mapping, trade filters, session status, last_event_at/last_error |
risk_profiles | A named, reusable bundle of copy-risk settings, attachable to many relationships — lot scaling, max drawdown/daily loss, SL/TP & partial-close sync, max open trades, hard position size limit, time filter (sessions/days), news filter (see docs/RISK_PROFILES.md) |
positions | Currently-OPEN trades per account — a live, mutable snapshot |
trades | Permanent history — every trade ever opened, source_trade_id links a copy back to its master, external_trade_id/platform_id link back to the broker's own id (used by PositionSynchronizationService) |
copy_logs | Append-only record of every copy attempt (success/failed/skipped), latency_ms, realtime-enabled for the live dashboard feed |
risk_rules | Configurable per-account safety limits (max daily loss, max lot size, …) |
subscriptions | Billing plan, synced from a payment processor |
ai_insights, notifications, audit_logs | Read-only-to-users, written by backend/AI jobs |
Key relationships:
erDiagram
trading_accounts ||--o| master_accounts : "may be a"
trading_accounts ||--o| follower_accounts : "may be a"
master_accounts ||--o{ copy_relationships : "sources"
follower_accounts ||--o{ copy_relationships : "targets"
risk_profiles ||--o{ copy_relationships : "optionally applies to"
copy_relationships ||--o{ copy_logs : "records attempts for"
trading_accounts ||--o{ trades : "owns"
trades ||--o| trades : "source_trade_id (master -> follower copy)"
broker_connections ||--o{ trading_accounts : "hosts"Queue processing
TradeEventQueue (src/lib/copy-engine/reliability/event-queue.ts) is the "Queue-based event processing" and "Out-of-order event handling" requirement made concrete:
- One FIFO lane per master trading account.
enqueue()never blocks;
each lane drains strictly in the order events were enqueued for THAT master — this is what "out-of-order handling" means here: a connector's own event ordering is trusted and preserved per master, never reordered or raced against itself.
- Different masters' lanes run concurrently with each other — this is
the "high-performance asynchronous processing" half: copying for master A is never blocked behind master B's processing.
- Idempotency is enforced at enqueue time, before an event ever
reaches a lane (IdempotencyStore, TTL-bounded, keyed by the signal's eventId when a real connector supplied one, or a composite fallback key otherwise). A duplicate never displaces or delays real work. A second, DB-level backstop — a unique index on trades (trading_account_id, external_trade_id) — catches the case an in-memory cache can't: a genuine duplicate arriving after a process restart or across two processes.
Retry and reconnect strategy
Three related but distinct mechanisms, each solving a different failure mode:
| Mechanism | Where | Handles |
|---|---|---|
ReconnectScheduler | one per connector instance | Exponential backoff delay sequence after an unexpected disconnect |
HeartbeatMonitor | one per connector instance | Periodic healthCheck() polling to *detect* a silently-dead connection (no error thrown, just stopped responding) |
PollLoop | TradeLocker/Tradovate position & price services | The "streaming" substitute for brokers with no push capability — never overlaps a poll with itself, isolates one tick's failure from the next |
withRetry (connectors: shared/http.ts's fetchJson; Copy Engine: reliability/retry.ts) | any single HTTP/persistence call | Transient failure (429/5xx, a flaky write) within ONE call, before it's treated as a real error |
Reconnect flow (identical shape across MT5/TradeLocker/Tradovate, despite different transports):
stateDiagram-v2
[*] --> disconnected
disconnected --> connecting: connect()
connecting --> connected: success
connecting --> error: failure
connected --> error: unexpected disconnect /<br/>heartbeat detects unreachable
error --> error: attemptReconnect() fails<br/>(schedule next backoff step)
error --> connected: attemptReconnect() succeeds<br/>(backoff reset)
connected --> disconnected: disconnect() (intentional)Every state transition into/out of connected is recorded once, in BaseConnector's state accessor (not duplicated per connector) as the Connection Uptime metric; each connector's own scheduleReconnect()/ attemptReconnect() additionally records reconnect-attempt counts explicitly, since a reconnect *attempt* isn't itself always a state transition (MT5, for example, retries directly from error without ever passing back through connecting).
Observability layer
src/lib/observability/ (new) is a neutral module both connectors/ and copy-engine/ depend on without crossing the "Copy Engine never imports connectors" boundary:
- `logger.ts` — structured (one-JSON-line-per-call) logging. Same
call shape ({debug,info,warn,error}(message, meta?)) the connector layer's ConnectorLogger interface already used, so every existing log call site across MT5/TradeLocker/Tradovate upgraded automatically with no call-site changes. LOG_LEVEL env var controls the console output threshold; every record (regardless of level) is also kept in a bounded in-memory ring buffer, which is what the Copy Engine health check's "recent errors" surfaces.
- `metrics.ts` — a global counter/gauge/histogram registry (the
standard pattern for this kind of cross-cutting concern — the same shape Prometheus client libraries use). Recording a metric has no effect on any method's return value or control flow, so it was safe to wire into already-tested code (BaseConnector, CopyEngine, TradeReplicationService, both REST sdk-adapter.ts files) purely additively.
- `health.ts` — the five health checks as pure(ish), independently
testable functions, each honest about what a stateless, request-scoped deployment can and can't verify (see its own extensive doc comment) — Database and self-test checks (Event Bus) are always fully live; Broker Connections and Copy Engine report DB-recorded/metrics-based status (richer when signed in); Market Data Streams reports registry/config state, not a live quote.
Metrics recorded today: connector_connected (gauge), connector_connection_duration_seconds (histogram — Connection Uptime), connector_reconnect_scheduled_total / _success_total / _failed_total (Reconnect Counts), connector_order_latency_ms (Order Latency), connector_order_failed_total (Failed Executions), connector_retry_total (Retry Counts), connector_error_total, copy_engine_copy_latency_ms (Copy Latency), copy_engine_failed_executions_total, copy_engine_retry_total, copy_engine_reconcile_recovered_total.
Health endpoints: GET /api/health (aggregate) and GET /api/health/{database,brokers,copy-engine,event-bus,market-data} (individual) — see docs/BROKER_INTEGRATION_REPORT.md's Validation Checklist for how to use them. Every health route accepts either a cookie session or an Authorization: Bearer <api key> header (see Authentication & Authorization below) via authenticateRequest().
Authentication & Authorization
Supabase Auth (auth.users, cookie-based JWT sessions via @supabase/ssr) is the identity provider; everything in this section is this app's own layer on top of it.
Session lifecycle. src/middleware.ts (root) runs before every request and delegates to updateSession() (src/lib/supabase/middleware.ts), which does two things with one request-scoped Supabase client: refreshes the session and persists any rotated cookies (Server Components can't write cookies themselves — see src/lib/supabase/server.ts — so without middleware, a refreshed refresh token is never actually saved, and a session silently dies once its access token expires), and gates /dashboard/* behind MFA step-up.
MFA. TOTP via Supabase's own supabase.auth.mfa API (enroll, challengeAndVerify, unenroll, listFactors, getAuthenticatorAssuranceLevel) — enrolled/managed at /dashboard/settings/security. A user with a verified factor who hasn't stepped up this session (currentLevel === 'aal1', nextLevel === 'aal2') is redirected to /mfa-challenge by both the middleware gate and login's own post-sign-in check.
Organizations & RBAC. Every user gets exactly one auto-created personal workspace (organizations, is_personal = true) at signup, in the same handle_new_user() trigger that creates their profiles row (supabase/migrations/20260807160000_organizations_and_rbac.sql). Membership (organization_members) carries one of four roles — owner > admin > member > read_only, a pure ordering encoded once in roleSatisfies() (src/lib/security/rbac.ts) and enforced twice: authoritatively by RLS (is_org_member()/org_role(), SECURITY DEFINER helpers that avoid the classic RLS-self-recursion trap), and again, redundantly but for a faster/clearer failure, by requireOrgRole() in each org-management Server Action. Invites (organization_invites) are tokenized links accepted via /invite/[token] and accept_organization_invite() — no email is ever sent (no provider is configured in this app), the inviter shares the link themselves.
Explicit scope boundary: in this milestone, organizations govern membership/invites/roles/settings/an org-scoped Billing view/an org-scoped Audit Log view — not access to any trading data. broker_connections, trading_accounts, positions, trades, copy_relationships, master_accounts, and follower_accounts are all still owned and RLS-scoped by user_id alone, exactly as in the original schema (whose own header comment already states "there is no cross-user marketplace in this schema"). The organization_id column added to subscriptions and audit_logs (originally nullable/backfilled, now not null and unique on subscriptions — see Billing below) exists specifically so a future milestone could extend real shared trading-resource access by repeating that same add-column-and-backfill-and-RLS-policy pattern on the trading tables, rather than inventing one from scratch.
Billing. That forward-compatibility groundwork is exactly what a later milestone used: subscriptions.organization_id is now the authoritative, unique billing key — one Stripe subscription per organization, whose plan tier gates each member's own usage limits. See docs/BILLING.md for the full picture (plan tiers, webhook handling, what's actually enforced vs. recorded-only, and the validation checklist).
Rate limiting. check_rate_limit() — a Postgres fixed-window counter (supabase/migrations/20260807161500_security_infrastructure.sql), deliberately DB-backed rather than in-memory: this app has no persistent process (see "What this deployment model does and doesn't give you" above), so an in-memory counter would reset every cold start and never be consistent across concurrent instances anyway. Applied to login, signup, password-reset requests, and MFA challenge attempts (src/lib/security/rate-limit.ts), and fails open (never blocks) if the check itself errors.
API authentication. Personal access tokens (api_keys — SHA-256 hash stored, plaintext shown once) authenticate requests via Authorization: Bearer <key>, checked by authenticateRequest() (src/lib/security/api-auth.ts) after the ordinary cookie session. Wired into the health endpoints as the concrete, working demonstration. Bearer tokens are also inherently immune to CSRF (no ambient browser credential to ride along on), unlike cookies.
CSRF/XSS. Next.js Server Actions verify the request's Origin header automatically (framework default, nothing to configure) — this is the CSRF protection for every form in this app. XSS: React/JSX escapes by default and this codebase has no dangerouslySetInnerHTML anywhere (the MFA QR code is rendered as a plain <img src> from a Supabase-supplied data:image/svg+xml string, never injected markup). next.config.ts adds a Content-Security-Policy and the standard defense-in-depth headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Strict-Transport-Security) on every response.
Audit logging. audit_logs (schema-only until this milestone — see its own comment in the initial migration: writes are RLS-restricted to the service role) is now written by logAuditEvent() (src/lib/security/audit-log.ts, via createServiceRoleClient()) from every sensitive action: login/logout, signup, password reset/change, MFA enroll/verify/unenroll, session revocation, API key create/revoke, and every organization-management action. Best-effort — a logging failure never blocks the action it's describing.