SoW: Desk management / Cross-account self-trade prevention

Master: SoW: Desk management, step 8.

Tracking: A-5032. Customer need: Blockhouse asked for a second trading account on 2026-09-17. TTG and Axtior need accounts that do trade against each other.

Delivers: ticket W5.5 and the self-trade part of decision D3 in Accounts and transfers. Uses: customers and trading_accounts.customer_id from Customer account access. Does not change: the parent pointer in Account families.

Objective. AX can state, per set of accounts, whether those accounts may trade against each other. The venue enforces the answer. The default does not change: an account is prevented only against itself.

The problem

A customer can hold several trading accounts. Two kinds of customer ask for this, and they need opposite behavior:

Customer shape Example Accounts trade against each other?
Independent pods. Separate decision-makers, no shared strategy. TTG Yes. A cross is a real trade between two books.
One trading firm. Several books, one decision-maker or one strategy set. Blockhouse (to confirm) No. A cross is a wash trade.

Today AX can only give the first behavior. No setting gives the second.

Current behavior

The model

Prevention scope is an explicit key, not a customer, user, or UBO:

customer -> self_trade_prevention_group -> trading_account
Record Meaning Table
Self-trade prevention group A set of accounts that must not trade against each other. Owned by one customer. self_trade_prevention_groups (new)
Membership The group an account belongs to, or none. trading_accounts.self_trade_prevention_group_id (new column)

An account with no group keeps today's behavior. Accounts in the same group are prevented against each other in addition to themselves. Accounts in different groups, or with no group, can trade against each other even when the customer is the same.

This is the same shape as margin groups and for the same reason. Common ownership does not decide risk pooling, and it does not decide prevention scope. TTG and Blockhouse are both "one customer with several accounts".

Decisions

  1. The key is explicit. customer_id does not imply a group. AX assigns groups.
  2. No group is the default. The schema adds no group rows. Behavior for every existing account is unchanged.
  3. One account has at most one group. The venue compares one key per order. A column carries it; there is no membership table.
  4. A group belongs to one customer. Every member account has the group's customer_id. A group across two customers (one UBO, two onboarded entities) is refused in this release. See open question 1.
  5. A customer can own several groups. A customer with pods of two accounts each gets one group per pod.
  6. AX sets groups. Customers do not. Removing prevention between the accounts of one firm is a market-abuse control. A customer manager can see the group of each account and cannot change it.
  7. A second account requires an explicit choice. When AX creates an account for a customer that already owns one, the operator must name a group or state that the account is independent. There is no silent default for the second account.
  8. A membership change requires no open orders on the account. The route refuses otherwise, or cancels them when the operator asks. This removes the question of what key a resting order carries.
  9. The per-order instruction keeps its meaning inside a group. An aggressor on account A with CancelResting cancels the resting order on account B. A trader on A can therefore cancel B's order without a grant on B. This is accepted: the customer asked for A and B to act as one book for prevention. Record it in the customer notice.
  10. Independent accounts stay under surveillance. A customer with ungrouped accounts can self-cross. Surveillance treats those crosses by customer_id, not by group. This SoW does not build that; it must not hide it.

Venue mechanism

EP3 gives two candidate mechanisms. Stage 0 tests both on ep3-dev and picks one. Everything after stage 0 depends only on this contract:

The venue key of an account is its self_trade_prevention_group_id, or nothing. Two orders with the same non-empty venue key do not match.

Candidate 1: per-order self-match prevention ID (preferred if it works)

The gateway sets InsertOrderRequest.self_match_prevention_id to the account's self_trade_prevention_group_id, and leaves it empty for an account with no group. FIX tag 7928 has the same meaning. The exchange-level account and participant scopes stay on and still cover the single account.

Candidate 2: beneficial-owner scope

This is the idea in the original accounts proposal ("use the EP3 Legal Entity Identifier as a self-trade identifier"). An EP3 account carries beneficial_ownership, a list of UserAttributes. Each entry has legal_entity_identifier and ownership_key. The proto comment on ownership_key says that equal keys on two accounts mark the accounts as related. AX writes the self_trade_prevention_group_id to one entry per grouped account with UpdateAccount, and turns on selfMatchPreventionBeneficialOwner.

Do not set client_account_id on orders under either candidate. Connamara confirmed on 2026-06-02 that a different client_account_id on the same base account disables account-level prevention (omnibus behavior).

Schema

CREATE TABLE self_trade_prevention_groups (
    id CHAR(16) PRIMARY KEY,
    customer_id CHAR(16) NOT NULL REFERENCES customers(id),
    name TEXT NOT NULL,
    created_by_user_id CHAR(16) REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX self_trade_prevention_groups_customer_id_idx ON self_trade_prevention_groups (customer_id);

ALTER TABLE trading_accounts
    ADD COLUMN self_trade_prevention_group_id CHAR(16) REFERENCES self_trade_prevention_groups(id);
CREATE INDEX trading_accounts_self_trade_prevention_group_id_idx ON trading_accounts (self_trade_prevention_group_id);

Invariants that a CHECK cannot reach. The write paths enforce them and a recon check reports violations:

No backfill. trading_accounts is already on every logical replication publication, so the column reaches order-gateway with no publication change. self_trade_prevention_groups is read from Postgres directly; it is not on the order path.

Routes and CLI

POST   /admin/customers/{id}/self-trade-prevention-groups   { name } -> group
GET    /admin/customers/{id}/self-trade-prevention-groups   -> [group + accounts]
DELETE /admin/self-trade-prevention-groups/{id}             -> 204 (empty group only)
PUT    /admin/accounts/{id}/self-trade-prevention-group
       { self_trade_prevention_group_id | null, cancel_open_orders }

Rules on PUT /admin/accounts/{id}/self-trade-prevention-group:

POST /admin/accounts (create account): when the customer already owns an account, the request must carry self_trade_prevention_group_id or self_trade_prevention_independent: true. Else 400 with a message that names the choice.

The admin CLI gains self-trade-prevention-groups create, self-trade-prevention-groups list, accounts set-self-trade-prevention-group, and self-trade-prevention-groups report. The report lists every customer with two or more accounts, the group of each account, and the accounts with no group. Compliance reviews it.

GET /whoami account entries and GET /customer/accounts carry self_trade_prevention_group (the group id and name, or null). The public SDK describes it as "accounts in the same group do not trade against each other". It names no venue field.

Order gateway

Candidate 1: prepare_ep3_insert_order_request takes the account's venue key and sets self_match_prevention_id. The replica struct for trading_accounts gains self_trade_prevention_group_id. No other order path inserts customer orders.

Candidate 2: no order-path change.

Both: the reject and cancel texts for a prevented match say "your own resting order" (rs/order-gateway/src/exchange_reject.rs). Change them to name a related account when the resting order is on a different account, without naming that account to a user who has no grant on it.

Mock and tests

ep3-mock models the chosen mechanism, so CI tests the same contract as the venue. find_self_trade_orders matches on the same participant or the same non-empty venue key.

The STP battery gains a cross_account section. It runs against ep3-mock in CI and against a live venue from the admin CLI, as the battery does today. This is the W5.5 acceptance:

Delivery

Stage Scope Reviewers
0 Venue spike on ep3-dev as an admin CLI probe (ep3-stp-scope-probe, beside the existing ep3_*_probe commands). The probe talks to EP3 directly, provisions two participants, and answers every "Unknown" above for both candidates. Its output is the evidence. Ask Connamara to confirm the result and the restart requirement. Record the choice in this document. backend
A Schema, replica types, recon checks. Inert. Needs step 2 of the master SoW (A-4890) for customers. high-risk
B Venue key on the order path (candidate 1) or the EP3 account writer and reconciler (candidate 2). ep3-mock change. Battery cross_account section. backend
C Admin routes, create-account choice, CLI, report. backend
D Admin GUI: group on the account page, group list on the customer page. Customer app: read-only group label. Public docs. frontend
0 ── A ── B ── C ── D

Stage 0 needs no schema and can start now. Rollout: sandbox, then demo with the live battery, then production. Candidate 2 adds a production maintenance window before C is usable.

Record the stage 0 result and the first production group as the D3 self-trade evidence in the master SoW decision record.

Until this ships

AX can create a second account for a customer. The accounts can trade against each other. Before it does so, AX asks the customer which shape they are and tells them in writing that AX does not prevent crosses between the accounts yet. A one-firm customer must prevent crosses on their side until stage C.

Do not give two AX accounts the same EP3 participant or EP3 account to get prevention early. One AX account to one EP3 identity is an invariant of order routing, event routing, positions, and reconciliation.

Out of scope

Open questions

  1. One UBO can own two onboarded customers. Should a group span them? The venue key allows it. Decision 4 refuses it until Compliance asks for it.
  2. Which shape is Blockhouse?
  3. Does Compliance need a stated reason on file for each independent multi-account customer, and where does it live?
  4. Should liquidation orders carry the venue key? Under candidate 1 the gateway can omit it, so that a liquidation can cross a sibling account. Under candidate 2 it cannot.