Risk-watcher pattern
Stable.
A risk-watcher is an automated process that monitors your account's health and intervenes — depositing margin, reducing position, or trading defensively — before the protocol's tiered liquidation ladder fires on you.
Production trading bots that hold positions overnight should run one. The protocol's T0 yellow card buys you exactly one committed block; a risk-watcher uses that block productively. Block cadence is a governed, per-deployment target, not a fixed duration — measure your own deployment's committed-round rate if your reaction budget depends on the wall-clock size of that window.
TL;DR
Subscribe to notifications for tier transitions and account_state for the continuous margin values, add margin via UpdateIsolatedMargin (Isolated) or Deposit (Cross) before the maintenance requirement becomes binding.
Architecture
The watcher is a separate logical process even when co-located — its decisions are independent of the trading strategy's decisions. A common failure mode is conflating "should I close this position?" with "should I take this trade?"; risk-watchers answer only the first.
Inputs
notificationsWS push: tier transitions (yellow_card/forced_close_tier/tier_cleared/forced_close) — the immediate signal that a tier changed.account_stateWS push: liveaccount_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-looking estimation, andfunding.rate_per_hr/funding.next_payment_tsper market to anticipate the next funding charge before it settles.user_fundingsWS push: realized funding payments — one record per settlement, AFTER it applies. This channel cannot anticipate the next charge; use themarketsrow'sfundingblock 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 | Pre-empt 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 | Funding charge can flip you into T0 |
| Mark moves > 3× recent-1h sigma in 30s | Snapshot positions + alert operator | Possible regime shift |
Tune thresholds to your strategy. Aggressive market-makers: tighter buffers (ratio 1.3 floor). Conservative books: looser (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 live 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
- Separate agent for watcher. Trader's agent does trading; watcher's agent does margin management. Compromise of trading host doesn't enable margin manipulation.
- Watcher's authority. Agents can submit
UpdateIsolatedMarginand place / cancel orders. Agents CANNOT withdraw, so the watcher can't move funds off the account — only between sub-buckets. This is desired. - Watcher's nonce space. Watcher and trader share the master's nonce space (per agent wallets). Use
Date.now()on both — collision risk is sub-millisecond.
Pre-deposit math
To bring your derived ratio from H₀ to target H₁ (H here is the ratio from two meanings of health, 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 your watcher's per-block deposit to avoid spending too much on a transient regime. Aggressive default: 1× position notional reserved for top-ups; once exhausted, escalate to operator.
Sequence — pre-emptive top-up
Failure modes
- Watcher and trader race. Trader submits a new position; watcher reacts to the in-flight position. Resolve: only react after commit (margin events fire on commit, so this is already the case).
- Watcher's own agent expired. Mid-stress, watcher can't act. Mitigation: tight rotation cadence, monitoring of agent expiry, never < 24h to expiry.
- Mempool full during stress. Watcher's deposit gets 503'd. Backoff with exponential jitter; submit at most every 100ms.
- Deposit succeeds but oracle stays bad. The deposit raises account_value; if maint also rose (mark moved against you), health may not improve enough. Loop: re-evaluate after commit; deposit again or unwind.
When NOT to deploy a risk-watcher
- Very short-lived positions (open and close within a single block) — health doesn't matter.
- Pure spot trading with no margin — no liquidation ladder applies.
- Fully isolated single-position bots where you've explicitly accepted the bucket loss limit — automating top-ups defeats the firewalling.
See also
- Tiered liquidation — the ladder you're defending against
notificationsWS — tier transitions ride this channelaccount_stateWS — continuous margin valuesclearinghouse_stateWS — the position rows an unwind needsupdate_isolated_margin- Agent wallets — watcher needs its own approved agent
- Error handling — for the deposit submission retry logic