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.
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.
One AX trading account is one EP3 participant and one EP3
account. trading_accounts.ep3_username and
ep3_account are unique and generated per account
(rs/ep3/src/ep3_user_manager.rs). Orders route as the
account's participant, not the session user
(rs/order-gateway/src/rest_service.rs,
Ep3Identity::resolve).
EP3 has exchange-level self-match scopes. The production settings, as read on 2026-03-31:
selfMatchPreventionClearingMemberFirm: false
selfMatchPreventionParticipantFirm: false
selfMatchPreventionAccount: true
selfMatchPreventionParticipant: true
selfMatchPreventionBeneficialOwner: false
Thus the scope of prevention today is the single account. Two users on one account are prevented. One user on two accounts is not. Two accounts of one customer or one UBO are not.
The per-order self_trade_prevention field only
selects the outcome of a prevented match: cancel the aggressor
(default), cancel the resting order, or cancel both. It does not set the
scope. The gateway sends self_match_prevention_instruction
and never sends self_match_prevention_id.
ep3-mock matches on the participant only
(rs/ep3-mock/src/order_entry_service.rs,
find_self_trade_orders). The STP battery
(rs/sdk-internal/test-batteries/src/stp_scenarios/) tests
one account and two unrelated accounts. It has no related-account
case.
On the Bitnomial edition the venue owns prevention. This SoW does not apply there.
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".
customer_id does
not imply a group. AX assigns groups.customer_id. A group across two
customers (one UBO, two onboarded entities) is refused in this release.
See open question 1.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.customer_id, not by group. This SoW does
not build that; it must not hide it.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.
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.
trading_accounts is already on the
order-gateway replica, so the key is a field read on the order
path.CancelReplaceOrderRequest has no ID field. Stage 0 must
show that a replacement keeps the original order's ID.ep3-dev while the
exchange-level scopes were off, but the test used one participant.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.
ep3-dev (5 to 10
minutes). Production needs a maintenance window.UpdateAccount takes a whole Account. The
writer must read, change one field, and write, or it can clear fields
that provisioning set.ownership_key,
the identifier, or the whole entry), and whether two accounts with
no entry count as equal. If empty equals empty, turning
the flag on would prevent every account against every other account. The
safe form then writes a key on every account: the group id, or the
account's own id.ownership_key, not the identifier, if both work.
The identifier is a real regulatory field.SetAssociatedAccounts with
SHARED_BENEFICIAL_OWNERSHIP is a third place the scope
could read. Stage 0 checks it.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).
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:
customer_id.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.
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:
cancel_open_orders is true. Then the route cancels them and
waits for the venue to confirm before it writes.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.
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.
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:
CancelResting and
CancelBoth act on the other account's order. Repeat through
cancel-replace.| 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.
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.