Risk control

Loss is budgeted before it is taken

T3 Trade enforces risk limits in code rather than relying on model instructions. Each mission has a fixed cumulative-loss ceiling, a 14-item checklist before any position increase, a persistent block when that budget is exhausted, and protective stops on the exchange. Seven user controls can reduce or remove exposure even when the provider process is unavailable.

intent execution.requested budget §16.2 · Eq 1-6 guard §16.4 · guardAction preview §16.3 · 14 items wire POST /exchange
one intent, four risk gates: the budget snapshot, the exhaustion guard, the increase checklist, then the signed exchange order
14 + 2 exit-only
checklist items, §16.3
6
pure loss equations, §16.2
3
max emergency-close attempts, §17.5
7
user controls without the provider, §14.7

The ceiling is arithmetic, not opinion

A mission's mandate includes its cumulative-loss ceiling in maximumCumulativeLossUsd. Everything the mission has lost, is losing, and has queued to lose is measured against it by one pure function, evaluateLossBudget, in packages/trading-contracts/src/lossAccounting.ts:106-138. Property tests pin every equation, and the functions take already-reconciled inputs: they never read the exchange packages/trading-contracts/src/lossAccounting.ts:15-17.

One reader gathers those inputs: realised PnL and paid fees from the reconciled fills table, open-position risk from the position snapshots, reserved risk from the reservations table, plus the notional of accepted, unfilled resting entries for the aggregate caps apps/server/src/trading/TradingBudgetReader.ts:4-12. The closedPnl in those rows is the exchange's own attribution per fill, not a number T3 computes packages/contracts/src/trading.ts:132. Net funding reads as zero until a funding source is wired apps/server/src/trading/TradingBudgetReader.ts:115-118.

Two rules materially affect the calculation. First, profits never raise the ceiling. Equation 2 clamps realised loss used to max(0, -result), so a winning stretch drives used toward zero and stops there packages/trading-contracts/src/lossAccounting.ts:111-112. The risk policy encodes the same rule in the schema itself: positivePnlExpandsLossBudget: Schema.Literal(false). Not a boolean that happens to be false; a type that cannot hold true packages/trading-contracts/src/authority.ts:41.

Second, this budget calculation assigns zero directional risk when the stop price is missing. Separate validation still refuses a position increase without a valid stop. When the stop price is unknown, the loss-to-stop term contributes 0, because substituting a $0 stop would book the full notional of a long against the budget and exhaust it instantly packages/trading-contracts/src/lossAccounting.ts:39-51. Paid fees are never double-counted either: fees already paid live in the realised result and are never also reserved as unpaid open-position fees packages/trading-contracts/src/lossAccounting.ts:8-11.

The six equations, §16.2
Eq 1realized = closedPnl + netFunding - paidFeeslossAccounting.ts:108-109
Eq 2lossUsed = max(0, -realized)profits clamp to zero · :111-112
Eq 3openRisk = max(0, lossToStop) + exitFee + slipReservemissing stop contributes 0 · :47-60
Eq 4pendingRisk = plannedLoss + entryFee + exitFee + slipReservereserved, unfilled entries · :69-76
Eq 5used = lossUsed + openRisk + pendingRisk:119
Eq 6remaining = max(0, ceiling - used)exhausted when remaining <= 0 · :122,135

Eq 4 is also the reservation the checklist holds against the budget and the execution service persists before signing apps/server/src/trading/TradingPreviewService.ts:379-393.

Fourteen questions, in an order that matters

Before any position-increasing action is signed, it runs the §16.3 checklist: 14 items in their listed order, each with its own rejection reason, plus two exit-only items apps/server/src/trading/TradingPreviewService.ts:40-70. The service is pure validation over already-loaded state: it reads no exchange and mutates no tables apps/server/src/trading/TradingPreviewService.ts:10-15. The checklist order affects safety apps/server/src/trading/TradingPreviewService.ts:444.

01mission_active
The mission must be executing or holding a position; anything else is refused with a sentence, not a code TradingPreviewService.ts:193-199
02entries_allowed
The entries switch is on and the mission is not blocked TradingPreviewService.ts:201-207
03harness_run_owns_lease
The requesting harness run holds the decision lease for this mission TradingPreviewService.ts:209-217
04direction_permitted
Allowed directions, plus the scale-in, partial-reduction, and reversal flags the authority actually enforces TradingPreviewService.ts:239-272
05execution_wallet_approved
An armed execution wallet, fail-closed on null so an unarmed signer is visible before a nonce is spent TradingPreviewService.ts:286-298
06account_and_bbo_fresh
BBO observed within 2 seconds, account state within 5 TradingPreviewService.ts:300-310
07size_and_price_valid
Positive size and positive limit price TradingPreviewService.ts:312-315
08exchange_minimum_met
Notional at or above the exchange minimum TradingPreviewService.ts:317-325
09leverage_within_limits
Combined existing plus proposed notional over allocated capital, so a scale-in cannot slip under a per-order ceiling TradingPreviewService.ts:339-351
10gross_notional_within_authority
The same combined measure against the gross cap, with resting-entry notional counted TradingPreviewService.ts:353-361
11planned_loss_within_per_position_ceiling
What the stop would lose, against the per-position ceiling TradingPreviewService.ts:371-377
12reservations_plus_proposed_within_budget
The Eq 4 reservation must fit what remains; an exhausted budget permits only exits TradingPreviewService.ts:395-411
13no_conflicting_execution_pending
One mid-submission execution at a time, and the rejection names the blocking record, its status, and its age TradingPreviewService.ts:413-421
14valid_stop_defined
A stop on the losing side of the entry: the first of its two evaluations TradingPreviewService.ts:423-442
E1position_exists
Exit-only: there must be something to reduce or close; §16.3 has no word for exiting a position you do not hold TradingPreviewService.ts:54-63
E2market_is_eth
Exit-only: an exit must land in the mission's mandated market, the one way the exposure is actually removed TradingPreviewService.ts:64-69

An exit runs the nine checks about executing it correctly and drops every check about whether more exposure should be permitted. Those entry rules once blocked exits when risk was already elevated. A blocked mission, disabled entries, or an exhausted budget could each prevent the action needed to reduce exposure apps/server/src/trading/TradingPreviewService.ts:462-483. The exit list also drops the exchange minimum, because refusing to close a dust position is the minimum inverted apps/server/src/trading/TradingPreviewService.ts:515-523.

The stop gate runs twice

Preview checks the stop against the harness's limit price. The execution service then runs the identical checkStopInformation again against the price actually going on the wire, the BBO-derived limit for a marketable IOC, before anything is persisted and before a nonce is spent apps/server/src/trading/HyperliquidExecutionService.ts:605-624. A position increase is submitted only if both stop checks pass.

When the budget is gone, the mission stops

Exhaustion is a computed flag, not a judgement: exhausted when remaining budget is at or below zero packages/trading-contracts/src/lossAccounting.ts:135. What happens next is a fixed sequence run by the execution guard, and it runs to the end regardless of who is watching.

STEP 1

Block increases at the gate

guardAction refuses every position-increasing action while the budget is exhausted. Cancel, reduce, close, and modify_stop still pass, because §16.4 blocks taking on risk, not managing the risk already open apps/server/src/trading/TradingExecutionGuard.ts:172-184, packages/trading-contracts/src/lossAccounting.ts:145-158.

STEP 2

Cancel resting increasing orders

blockForExhaustion reads the mission's resting position-increasing orders and attempts to cancel each one. The cancellation report is returned, never swallowed (RC03), but an unconfirmed cancel does not stop the block: abandoning the safety block because one cancel would not confirm would trade a bounded uncertainty for a mission that keeps increasing exposure apps/server/src/trading/TradingExecutionGuard.ts:196-219.

STEP 3

Mark the mission blocked

Transition to blocked with reason cumulative_loss_limit apps/server/src/trading/TradingExecutionGuard.ts:221-227.

STEP 4

Reconcile every held market

So local order rows reflect the cancels before the blocked status is announced; every market the mission holds, because the exhaustion cancelled orders on all of them apps/server/src/trading/TradingExecutionGuard.ts:240-255.

STEP 5

Stay blocked until the user says otherwise

guardResume rejects a harness resume: only an explicit user resume after revalidation clears it, and the harness's own resume request is refused while blocked apps/server/src/trading/TradingExecutionGuard.ts:259-271, apps/server/src/trading/TradingExecutionGuard.ts:5-9. No path lets the model spend its way back into a budget it already spent.

An error is not a verdict

The guard keeps budget_exhausted and infrastructure_error apart because a stuck execution record was once reported to the harness as budget_exhausted inside a payload that also said exhausted: false, sending it down a recovery path that could never work apps/server/src/trading/TradingExecutionGuard.ts:61-71. A SQL read that fails is not the budget saying no.

Ceilings in dollars, and they only tighten

The mandate's four ceilings are maximumLeverage, maximumGrossNotionalUsd, maximumCumulativeLossUsd, and maximumPlannedRiskPerPositionUsd. Testnet defaults for allocated capital C: 20x leverage, 8x C notional, 35% of C cumulative loss, 7% of C per position, with the percentages written as integer math so a mandate never carries a float artifact like 35.000000000000004 packages/trading-contracts/src/authority.ts:179-189.

An operator can move the four numbers without a rebuild through environment variables: T3_TRADES_AUTHORITY_MAX_LEVERAGE, T3_TRADES_AUTHORITY_MAX_GROSS_NOTIONAL_USD, T3_TRADES_AUTHORITY_MAX_CUMULATIVE_LOSS_USD, and T3_TRADES_AUTHORITY_MAX_PLANNED_RISK_USD apps/server/src/trading/TestnetAuthority.ts:13-16. All four are absolute values, not multiples of capital: an operator raising a ceiling is thinking in dollars about a specific account, not in ratios apps/server/src/trading/TestnetAuthority.ts:18-19. An invalid value falls back to the default instead of preventing mission creation apps/server/src/trading/TestnetAuthority.ts:30-35.

The knobs shape size and nothing else. Nothing here can grant direction reversal, switch margin mode, or touch the risk policy: those are authority the user grants explicitly, not a number in the environment apps/server/src/trading/TestnetAuthority.ts:21-23. The risk-policy page of this atlas covers where the mandate itself comes from; see Signer & authority.

Defaults per $100 of capital
lev20x maximum leverageauthority.ts:182
grs$800 maximum gross notional8x capital · authority.ts:183
cml$35 cumulative loss ceilingroughly four full per-position risks · authority.ts:168-170
ppr$7 planned risk per positionfunds a 0.9% stop at the largest entry · authority.ts:171-174

The gross cap is deliberately sized to what the loss budget can protect, not to what the margin allows packages/trading-contracts/src/authority.ts:163-167.

Stops that live on the exchange

The invariant

No acknowledged position increase may remain without confirmed exchange-native reduce-only protection beyond the bounded reconciliation window apps/server/src/trading/TradingProtectionService.ts:4-9. A stop in T3 Trade is a real reduce-only trigger order resting on Hyperliquid, not a local promise to act later.

Linked at birth

An increase and its stop go out in one normalTpsl action. The child is sized to the requested size; reconciliation resizes it to the canonical position apps/server/src/trading/HyperliquidExecutionService.ts:626-634.

Independent replacement stops

When coverage is short after a partial fill, a scale-in, or a parent cancellation that took its children, submitProtectiveStop places an explicitly sized reduce-only stop with na grouping, so it is nobody's child and outlives the parent apps/server/src/trading/HyperliquidExecutionService.ts:143-160.

Place, then cancel

The replacement stop is confirmed before the old one is dropped. Overlapping reduce-only protection is harmless because reduce-only orders cannot open exposure; a gap between the cancel and the placement is exactly the window the invariant forbids apps/server/src/trading/TradingProtectionService.ts:23-28.

Coverage is read

A grouped response is not proof: the submitted child may be untriggered, rejected, or already cancelled, so coverage is read back from frontendOpenOrders every time apps/server/src/trading/TradingProtectionService.ts:18-22.

Take-profit is a wake

The target is deliberately not an exchange-native order anymore; reconcileTakeProtection withdraws take-profit orders that earlier builds rested apps/server/src/trading/TradingProtectionService.ts:33-40.

The watchdog

Every five seconds the reactor's protection guard re-reads coverage. When the window closes uncovered it escalates straight to emergency close and wakes the harness apps/server/src/trading/TradingMissionReactor.ts:2542-2563, cadence apps/server/src/trading/TradingMissionReactor.ts:2888.

Emergency close follows a bounded sequence

The emergency close runs when full protection cannot be confirmed inside the reconciliation window. §17.5 calls it a deterministic safety action, not a strategy decision: it runs without the harness, in a fixed order, a fixed number of times, and never waits for anyone to wake up and help apps/server/src/trading/TradingEmergencyCloseService.ts:4-7. The order is the specification, and each step exists because of the failure it prevents apps/server/src/trading/TradingEmergencyCloseService.ts:9-32.

01

Block increases

Mark the mission blocked and block every position increase first; otherwise the thing being unwound can grow while it is being unwound.

02

Cancel non-reduce-only orders

A resting entry that fills mid-close re-opens the exposure just closed. Reduce-only orders are left alone: they are the protection.

03

Read fresh canonical position and BBO

The size closed must be the size that exists now, not the size that existed when the trouble started.

04

Submit a reduce-only marketable IOC

For exactly that size, priced off the side it will cross.

05

Reconcile fills and the remaining position

Canonical state answers what the attempt actually did.

06

Retry with a fresh read and the remaining size

At most three attempts in total TradingEmergencyCloseService.ts:50-51. An IOC fills what it can and cancels the rest, so a partial close is the expected case, not an error.

07

Still open: say exactly what is left

Keep the mission blocked, preserve whatever protection can be placed, and report the exact remaining size and reason.

FLATclaimed only from a canonical read
OPENsigned remaining size, from a canonical read
UNKNOWNremainingSize: null, never a guessed number

The outcome is a narrow three-way union: flat, open, or unknown, with remainingSize: null whenever the canonical size could not be confirmed apps/server/src/trading/TradingEmergencyCloseService.ts:63-117. One rendering, describeEmergencyCloseOutcome, is shared by every caller so the three reactor call sites cannot disagree (RC04) apps/server/src/trading/TradingEmergencyCloseService.ts:119-133. The full anatomy of an order's path to the exchange is on the Execution page.

Seven controls that do not ask the model

None of the §14.7 controls may require a harness turn: a user who wants out of a position must be able to get out while the provider process is dead, the session is unreachable, or the model is mid-thought apps/server/src/trading/TradingControlService.ts:2-8. The risk-reducing controls therefore take the preview-free reduce-only path, which is safe for a different reason: the exchange itself will not let a reduce-only order open or extend a position apps/server/src/trading/TradingControlService.ts:10-17.

pause

Block new entries, scale-ins, reversals, and re-entry. Protection stays live TradingControlService.ts:144-145.

resume

Re-enable a paused mission. A mission blocked for cumulative loss is not resumable here; that is §16.4 TradingControlService.ts:147-148.

cancelEntries

Cancel every resting position-increasing order, protecting any filled slice first. The position itself is left alone TradingControlService.ts:150-156.

reducePosition

25 / 50 / 75 / 100 percent of the canonical position, via a reduce-only IOC TradingControlService.ts:158-161.

closePosition

Close the canonical position entirely TradingControlService.ts:163-166.

revoke

End autonomous authority permanently, preserving any valid protection TradingControlService.ts:168-169.

closeAndRevokeMission

Close every held market, then revoke: the one-click way out. Authority ends exactly once, only after canonical flat is confirmed across the whole held set (RC01) TradingControlService.ts:171-181.

What is not bypassed

The buttons bypass discretionary harness reasoning, never T3's safety boundary. Every control still goes through the signer, the nonce lane, canonical reconciliation, and, where it matters most, the protection reconciliation, so cancelling entries cannot strip a partial fill of its stop apps/server/src/trading/TradingControlService.ts:19-24.

An unconfirmed close says unknown

A submitted-but-unconfirmed close must report CLOSE_OUTCOME_UNKNOWN: "Close outcome unknown: an order may have executed; position could not be confirmed." Never a numeric size, never "Already flat", never "Position closed" apps/server/src/trading/TradingControlService.ts:78-84. A reduce makes at most two bounded attempts before reporting back apps/server/src/trading/TradingControlService.ts:227-228.

Each control action leaves a persistent result

Before RC06, the outcome of a risk-control press lived only in server logs: a dispatched command proved the request was accepted, and the reactor's exchange work finished later where nobody could see it apps/server/src/persistence/Migrations/094_TradingControlResults.ts:9-12. The fix is the append-only trading_control_results table, one row per applied control with the control name, a completed, failed, or unknown status, the composed summary, per-market facts as JSON, and the sequence of the request event it answers apps/server/src/persistence/Migrations/094_TradingControlResults.ts:15-23. A trading.mission.control-result event rides the same stream as the doorbell that tells the client to refetch.

pending
The mission strip shows the control name while the result is outstanding: the press is held as pending with its dispatch time until lastControlResult arrives to correlate against it apps/web/src/components/trading/useMissionControls.ts:136-145
interrupted
A press whose result never landed stops waiting after CONTROL_RESULT_TIMEOUT_MILLIS = 30_000 and reads as interrupted apps/web/src/components/trading/useMissionControls.ts:68-69, apps/web/src/components/trading/useMissionControls.ts:120-121
unknown
After the timeout the strip renders the control name with the words no final result, outcome unknown apps/web/src/components/trading/MissionStripBar.tsx:140
durable
Rows are append-only, read latest-first, and deliberately non-authoritative: the exchange stays the truth; this is the record of what T3 Trade's control did about it apps/server/src/persistence/Migrations/094_TradingControlResults.ts:19-23

The same honesty rule appears at every layer of this page: the budget states what is left, the guard states which side of the gate an action fell on, the emergency close states flat, open, or unknown, and the control strip states that no final result means the outcome is unknown. Where the screens live is covered in Web & desktop; the stories behind these rules are in Failure stories, and the invariants they protect are collected in Safety invariants.

Go deeper

Server, risk services

  • apps/server/src/trading/TradingPreviewService.ts the §16.3 checklist and both orderings
  • apps/server/src/trading/TradingExecutionGuard.ts exhaustion enforcement and the reduce-only exits
  • apps/server/src/trading/TradingBudgetReader.ts assembles the §16.2 snapshot from reconciled tables
  • apps/server/src/trading/TradingProtectionService.ts the stop invariant and its repairs
  • apps/server/src/trading/TradingEmergencyCloseService.ts the bounded §17.5 unwinding
  • apps/server/src/trading/TestnetAuthority.ts the operator ceiling knobs

Contracts and clients

  • packages/trading-contracts/src/lossAccounting.ts the six pure equations and the exhaustion permission set
  • packages/trading-contracts/src/authority.ts mandate ceilings, defaults, and the risk policy schema
  • apps/server/src/trading/TradingControlService.ts the seven §14.7 controls
  • apps/server/src/persistence/Migrations/094_TradingControlResults.ts durable control outcomes (RC06)
  • apps/web/src/components/trading/useMissionControls.ts press-to-result correlation and the 30-second timeout
  • apps/server/src/trading/TradingMissionReactor.ts the five-second watchdog family and the escalation

Neighbouring pages: where the reconciled budget inputs come from is Reconciliation; what the guards wrap is Execution; the wire types that carry all of it are Contracts; the missions these budgets bound are Missions watches wakes.