Risk-watcher pattern
This page describes a risk-watcher: a process that protects an account from liquidation.
Stable.
A risk-watcher is an automated process that monitors your account's health. It acts before the protocol's tiered liquidation ladder fires on you. It can deposit margin, reduce a position or trade defensively.
Production trading bots that hold positions overnight should run one. The protocol's T0 yellow card gives you exactly one committed block. A risk-watcher uses that block to act. Block cadence is a governed target per deployment. It is not a fixed duration. If your reaction budget depends on the wall-clock size of that window, measure the committed-round rate of your own deployment.
Summary
Subscribe to notifications for tier transitions,
and to account_state for the continuous margin
values. Add margin before the maintenance requirement becomes binding: use
UpdateIsolatedMargin for Isolated, or Deposit for Cross.
Architecture
The watcher is a separate logical process, also when it runs on the same host. Its decisions are independent of the trading strategy's decisions. A common failure is to mix two questions: "close this position?" and "take this trade?". A risk-watcher answers only the first.
Inputs
notificationsWS push: tier transitions (yellow_card/forced_close_tier/tier_cleared/forced_close). This is the first signal that a tier changed.account_stateWS push: currentaccount_value,total_raw_usd,perp.total_ntl_pos,tier. The account-levelcross_maintenance_margin_usedis not on this push. Polldetail: "margin"for it. Derive your own health ratio fromaccount_valueandcross_maintenance_margin_used. See two meanings of health. The wirehealthfield is a signed dollar figure, not this ratio.marketsWS push:mark_pxfor forward estimates, andfunding.rate_per_hr/funding.next_payment_tsper market. Use them to predict the next funding charge before it settles.user_fundingsWS push: realized funding payments, one record per settlement, after it applies. This channel cannot predict the next charge. Use thefundingblock of themarketsrow for that.
Reaction rules
| Trigger | Action | Rationale |
|---|---|---|
| Derived ratio < 1.5 and falling for 5 consecutive samples | Pre-emptive deposit to bring the ratio to 1.8 | Buffer before T0 |
tier transition to T0 | Immediate deposit OR partial close | One block to act before T1 |
tier transition to T1 | Emergency: full close on highest-loss position | Close before the partial close at a worse price |
Projected charge from the markets row's funding (rate_per_hr × position notional, due at next_payment_ts) > 0.5 × withdrawable | Pre-pay deposit before settlement | A funding charge can move you into T0 |
| Mark moves > 3× recent-1h sigma in 30s | Snapshot positions + alert operator | Possible regime shift |
Tune the thresholds to your strategy. Aggressive market makers use tighter buffers (ratio 1.3 floor). Conservative books use looser buffers (ratio 1.8 floor).
Implementation sketch (TypeScript)
import { Client, isChannelFrame } from '@metaflux-dex/client';
const trader = new Client({ baseUrl, privateKey: traderAgentKey /* trading agent */ });
const watcher = new Client({ baseUrl, privateKey: watcherAgentKey /* dedicated watcher agent */ });
const traderAddr = '0x<MASTER_ADDRESS>';
const TARGET_RATIO = 1.8;
const T0_DEPOSIT_USDC = 1000; // tune to position size
interface MarginSummary {
account_value: string;
cross_maintenance_margin_used: string;
}
let recentSamples: number[] = [];
// The full `account_state` does NOT carry an account-level maintenance figure —
// only the signed-dollar `health` field. `cross_maintenance_margin_used`, which
// the ratio needs, lives ONLY on the margin-depth account read, and that read
// has no dedicated SDK wrapper — use the typed `raw` escape hatch and poll it.
// The scope is the CROSS bucket: this watcher does not cover an isolated leg.
async function pollMarginSummary() {
const summary = await watcher.info.raw<MarginSummary>({
type: 'account_state', detail: 'margin',
address: traderAddr,
});
const accountValue = Number(summary.account_value);
const maintMargin = Number(summary.cross_maintenance_margin_used);
const ratio = maintMargin === 0 ? Infinity : accountValue / maintMargin;
recentSamples.push(ratio);
if (recentSamples.length > 5) recentSamples.shift();
const allFalling = recentSamples.length === 5
&& recentSamples.every((h, i) => i === 0 || h < recentSamples[i - 1]!);
if (allFalling && ratio < 1.5) {
console.log('[INFO] pre-emptive top-up');
const needed = (TARGET_RATIO * maintMargin - accountValue).toFixed(2);
await deposit(watcher, needed);
}
}
// notifications fires exactly on tier transitions — react to `kind` directly
// instead of polling a threshold.
async function watchNotifications() {
const ws = await watcher.connectWs();
ws.onMessage(async (f) => {
if (!isChannelFrame(f, 'notifications')) return;
for (const record of f.data) {
if (record.kind === 'forced_close_tier') {
console.log(`[ALERT] ${record.tier ?? 'unknown'} — emergency unwind`);
await emergencyUnwind(trader);
}
if (record.kind === 'yellow_card') {
console.log('[WARN] T0 — top up');
await deposit(watcher, T0_DEPOSIT_USDC.toString());
}
}
});
await ws.subscribe({ type: 'notifications', user: traderAddr });
}
async function deposit(c: Client, usdcDelta: string) {
// Isolated: add to the bucket. For Cross, deposit via the bridge instead —
// Cross collateral is the account's one unified USDC balance.
await c.updateIsolatedMargin({ asset: 0, delta: usdcDelta });
}
async function emergencyUnwind(c: Client) {
// Positions sit on their own read now, not inside accountState. Never mix a
// number from this frame with one from an accountState frame: the two can be
// rendered a commit apart. Compare `height` if you must combine them.
const state = await c.info.clearinghouseState(traderAddr);
const positions = state.clearinghouse_state['']?.positions ?? [];
for (const pos of positions) {
// close the largest-loss position first — pick pos by unrealised PnL yourself
const size = Number(pos.size);
await c.submitOrderNative({
owner: traderAddr,
market: 0, // look up the market id for `pos.coin` via marketsMeta()
side: size < 0 ? 'bid' : 'ask', // opposite side closes
kind: 'market',
size: Math.round(Math.abs(size) * 1e6),
limit_px: 0,
tif: 'ioc',
stp_mode: 'cancel_newest',
reduce_only: true,
});
}
}
Key choices
- A separate agent for the watcher. The trader's agent trades. The watcher's agent manages margin. A compromised trading host therefore cannot manipulate margin.
- Watcher authority. Agents can submit
UpdateIsolatedMarginand place or cancel orders. Agents cannot withdraw. The watcher therefore cannot move funds off the account, only between sub-buckets. This limit is intended. - Watcher nonce space. The watcher and the trader share the master's nonce space, as
agent wallets describes. Use
Date.now()on both. The collision risk is below one millisecond.
Pre-deposit math
This formula moves your derived ratio from H₀ to a target H₁. H here is the ratio from
two meanings of health. It is not
the wire health field.
needed_deposit = (H₁ - H₀) × cross_maintenance_margin_used
Example: maintenance = 10 USDC, current health 1.0, target 1.5. needed = (1.5 - 1.0) × 10 = 5 USDC.
Cap the watcher's deposit per block, so that it does not spend too much on a short regime. An aggressive default reserves 1× position notional for these deposits. When the reserve is used up, escalate to the operator.
Pre-emptive deposit sequence
Failure modes
- Watcher and trader race. The trader submits a new position, and the watcher reacts to the in-flight position. Fix: react only after commit. Margin events fire on commit, so this is already the case.
- The watcher's own agent expired. Under stress, the watcher cannot act. Mitigation: a short rotation cadence, monitoring of agent expiry, and never less than 24h to expiry.
- Mempool full under stress. The watcher's deposit gets a 503. Back off with exponential jitter. Submit at most once every 100ms.
- The deposit succeeds, but the oracle stays bad. The deposit raises
account_value. If the maintenance requirement also rose because the mark moved against you, health can stay too low. Loop: re-evaluate after commit, then deposit again or unwind.
Cases that need no risk-watcher
- Very short-lived positions that open and close within one block. Health does not matter.
- Pure spot trading with no margin. No liquidation ladder applies.
- Fully isolated single-position bots where you accept the bucket loss limit on purpose. Automated deposits defeat the isolation.
See also
- Tiered liquidation: the ladder the watcher defends against
notificationsWS: tier transitions arrive on this channelaccount_stateWS: continuous margin valuesclearinghouse_stateWS: the position rows that an unwind needsupdate_isolated_margin- Agent wallets: the watcher needs its own approved agent
- Error handling: the retry logic for the deposit submission