# Lighter — account, positions, leverage, and margin

Reading your account (`account?by=index`), positions whose sign is stored separately, account value as equity, completeness of the order list, leverage as the initial margin fraction and `update_leverage`, two scales for the same fraction, and what is known about fees. Facts were verified on the robinhoodchain instance.

## TL;DR

1. **The account can be read publicly:** `GET /api/v1/account?by=index&value=<account_index>` → `accounts[0]`. No token is required; `l1_address` in the response is the owner, so it can be used before startup to confirm that the intended account is configured. *Verified 2026-08-20.*
2. **The position sign is stored separately:** `sign` (±1) and `position` (magnitude, always positive). Mixing them up opens the opposite side. `size = sign < 0 ? -|position| : |position|`. *Verified 2026-08-20.*
3. **Equity = `total_asset_value`.** All collateral is already included; there is no separate “free spot balance,” and adding a second value would only double-count the same funds. *Verified 2026-08-20.*
4. **Leverage on Lighter = the market’s initial margin fraction.** Set it with `update_leverage(market_index, margin_mode, leverage)`; the market default `default_initial_margin_fraction` of 5000 = 50% = **2x**. The cap is `floor(10000 / min_initial_margin_fraction)`. *Verified 2026-08-23.*
5. **Two scales for the same value:** `orderBookDetails.min_initial_margin_fraction` is in hundredths of a percent (`1000` = 10%, divide by 10,000); `account.positions[].initial_margin_fraction` is a percentage string (`"50.00"` = 50%, divide by 100). Confusing them costs two orders of magnitude: leverage becomes 200x instead of 2x, margin is understated 100-fold, and ROE is inflated by the same factor. *Verified 2026-08-23.*
6. **Under cross margin, the initial fraction is the opening requirement; liquidation uses the maintenance fraction.** Therefore, changing leverage under an open position is safe: collateral is released and the liquidation price does not move. *Verified live 2026-08-23.*
7. **If leverage was not set, the account remains at the 2x default** and ties up more collateral than at target leverage L (L/2 times as much). *Verified 2026-08-23.*
8. **The complete order list is returned by one request** (`accountActiveOrders` without `market_id`), but it **lags behind your own writes**. Its completeness is real; “could not read” is not “empty.” *Verified 2026-08-20.* → `orders.md` §6–7
9. **Fees have not been measured.** Public Lighter materials say that standard accounts trade without fees, but this has not been verified on the robinhoodchain instance and the fee field in fills has not been read. *Not verified.*

---

## 1. Reading the account

`GET /api/v1/account?by=index&value=<account_index>` → `{ accounts: [ … ] }`. Use `accounts[0]`; an empty array is a read error (“no such account” or degradation), not “empty account.”

| Field | How it is used |
|---|---|
| `index` | compare with configured `account_index` |
| `l1_address` | owner; compare in lowercase with the expected address; mismatch = abort startup |
| `total_asset_value` | equity |
| `collateral` | collateral |
| `available_balance` | available for orders; `total_asset_value − available_balance ≈ cross_initial_margin_requirement` (verified with exchange arithmetic 2026-08-23) |
| `cross_initial_margin_requirement` | initial-margin requirement for cross-margin positions |
| `total_order_count` | number of account orders |
| `positions[]` | see §2 |

Fail closed while parsing: if `total_asset_value` is not a number, a position lacks `symbol`, `sign`/`position` is malformed, or a symbol appears twice among positions, **the entire read is untrusted** (`ok:false`), rather than “the other positions are valid.” None of that snapshot may be trusted.

```ts
interface AccountSnapshot {
  positions: Map<string, { symbol: string; size: number; avgEntryPrice: number; positionValue: number;
                           leverage: number; marginMode: 'cross' | 'isolated'; unrealizedPnl: number }>;
  totalAssetValue: number; ok: boolean;
}
const BAD: AccountSnapshot = { positions: new Map(), totalAssetValue: 0, ok: false };
const num = (v: unknown) => { const n = Number(v); return Number.isFinite(n) ? n : NaN; };

function decodeAccount(a: Record<string, unknown>): AccountSnapshot {
  const totalAssetValue = num(a.total_asset_value);
  if (!Number.isFinite(totalAssetValue)) return BAD;
  const positions = new Map();
  for (const p of (Array.isArray(a.positions) ? a.positions : []) as Array<Record<string, unknown>>) {
    const symbol = typeof p.symbol === 'string' ? p.symbol.trim() : '';
    const mag = num(p.position), sign = num(p.sign);
    if (!symbol || !Number.isFinite(mag) || !Number.isFinite(sign)) return BAD;
    const size = sign < 0 ? -Math.abs(mag) : Math.abs(mag);   // sign comes from sign, magnitude from position
    if (size === 0) continue;
    if (positions.has(symbol)) return BAD;                     // duplicate symbol makes the snapshot untrusted
    const imfPercent = num(p.initial_margin_fraction);         // "50.00" = 50% (PERCENT)
    positions.set(symbol, {
      symbol, size,
      avgEntryPrice: num(p.avg_entry_price) || 0,
      positionValue: Math.abs(num(p.position_value) || 0),
      leverage: leverageFromAccountFraction(imfPercent),
      marginMode: String(p.margin_mode ?? '') === '1' ? 'isolated' : 'cross',
      unrealizedPnl: num(p.unrealized_pnl) || 0,
    });
  }
  return { positions, totalAssetValue, ok: true };
}
```

---

## 2. Positions

| Field | Type | Meaning |
|---|---|---|
| `market_id` | number | market |
| `symbol` | string | market name |
| `sign` | number | ±1 — direction |
| `position` | string | position magnitude (unsigned) |
| `avg_entry_price` | string | average entry price |
| `position_value` | string | notional |
| `unrealized_pnl` | string | unrealized PnL |
| `initial_margin_fraction` | string | initial fraction **in percent** (`"50.00"`), which determines position leverage |
| `margin_mode` | string/number | `"1"` = isolated, otherwise cross |

- Position leverage is visible only on an open position: the fraction is not visible on a flat account.
- Observed position leverage is a reason **not to spend a transaction** on `update_leverage` if it already equals the target. → §4
- Read positions and orders as a sandwich: positions → orders → positions. `stable = true` only if the position map is byte-for-byte identical before and after reading the orders. Otherwise, order sizes calculated from the position must not be trusted for that snapshot.

---

## 3. Two fraction scales (verified 2026-08-23)

| Source | Scale | Example | Conversion |
|---|---|---|---|
| `orderBookDetails.min_initial_margin_fraction`, `default_initial_margin_fraction` | hundredths of a percent | `1000` = 10%, `5000` = 50% | `/ 10_000` |
| `account.positions[].initial_margin_fraction` | percent, as a string with two decimal places | `"50.00"` = 50%, `"20.00"` = 20% | `/ 100` |

```ts
const imfFromMarket  = (raw: number) => raw / 10_000;        // 1000 -> 0.10
const imfFromAccount = (raw: string) => Number(raw) / 100;    // "50.00" -> 0.50
const maxLeverage    = (minImfRaw: number) => Math.max(1, Math.floor(10_000 / Math.max(1, minImfRaw)));

/** Position leverage from the account fraction (PERCENT). Garbage → 1x, not infinity: this number divides notional. */
function leverageFromAccountFraction(imfPercent: number): number {
  if (!Number.isFinite(imfPercent) || imfPercent <= 0) return 1;
  return Math.max(1, Math.round(100 / imfPercent));   // 50 -> 2, 20 -> 5, 10 -> 10, 100 -> 1
}
```

The scale is proven by the exchange’s own arithmetic: the sum of `notional × imf / 100` over all account positions equals `cross_initial_margin_requirement` exactly, and also equals the difference between `total_asset_value` and `available_balance`. Dividing by 10,000 instead produces 200x leverage instead of 2x, understates position margin by 100×, and inflates ROE by the same factor.

---

## 4. Leverage: `update_leverage`

### 4.1 Mechanics

- On Lighter, leverage is the market’s initial margin fraction for the account. Method: `SignerClient.update_leverage(market_index, margin_mode, leverage, api_key_index)`; through the sidecar: `POST /leverage { market_index, leverage, margin_mode? }`. `margin_mode`: `CROSS_MARGIN_MODE` = 0; isolated has not been verified.
- Without this call, the account uses the market’s `default_initial_margin_fraction`—50% = **2x**.
- Cap: `floor(10000 / min_initial_margin_fraction)`; requested leverage above the cap → clamp to the cap and warn.
- Every attempt is **a transaction and a write in the 40/60 s window**. After a market rejection, do not retry every tick; use a per-market cooldown (for example, minutes). A rejection by the local write limiter is not a market rejection, so the next tick may try again.
- The SDK response is a tuple; parse it tolerantly with respect to tuple length (the last element is `err`) so that an SDK version change does not become a silent “success.”
- Timeout = unknown outcome: reread positions (the fraction is visible there); do not retry blindly.

### 4.2 What was verified under an open position

Under **cross margin**, the initial fraction is the requirement for **opening**; liquidation uses the **maintenance** fraction. When leverage was changed under an open position on 2026-08-23, collateral was released and the liquidation price did not move. Therefore, leverage can be set after entry, not only before it.

### 4.3 Do not throw from leverage setup

On Lighter, order sizes do not depend on configured leverage, so “could not set leverage” means warn and trade with the current leverage, not stop trading.

```ts
const LEVERAGE_RETRY_MS = 10 * 60_000;   // example cooldown value
async function applyLeverage(symbol: string, marketIndex: number, cap: number, wantRaw: number) {
  const want = Math.max(1, Math.min(Math.round(wantRaw), cap));
  if (lastSetLeverage(symbol) === want || positionLeverage(symbol) === want) return;   // already set—do not spend a write
  if (inLeverageCooldown(symbol, LEVERAGE_RETRY_MS)) return;
  const r = await sidecarPost('/leverage', { market_index: marketIndex, leverage: want, margin_mode: 0 });
  if (r.ok) { rememberLeverage(symbol, want); return; }
  if (r.rateLimited) return;              // write window—not a market rejection
  startLeverageCooldown(symbol);          // per-market cooldown, not global
}
```

The “set / observed” memory lives in the process: after restart, it is cheaper to query each market once than to persist something the exchange may have changed independently.

---

## 5. Equity, utilization, protective triggers

- `total_asset_value` includes all collateral; there is no “free spot balance” outside equity to add to it.
- One read is not a fact: for drawdown triggers, use the median of several reads. zk-rollup lag after IoC is measured in seconds (poll for up to ~4 seconds); after cancellations, the book can lag by tens of seconds.
- Calculate margin utilization with the account’s **actual** leverage (from the position `initial_margin_fraction`, §3), not the target leverage: without `update_leverage`, the account remains at the 2x default and the check would see the wrong utilization.
- The liquidation formula from the maintenance fraction has not been derived or verified.

---

## 6. Fees

- Fees have not been measured: fills with a fee field were not read.
- Public Lighter materials say standard accounts trade without maker/taker fees, while “premium” accounts pay them. This is **not verified** for the robinhoodchain instance; before calculating economics, measure the actual fee from your own account’s trade history.

---

## Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| Opposite side is opened | sign was read from `position` (which is always positive) | `sign` × `position` |
| Leverage 200x, margin /100, ROE ×100 | account fraction was divided by 10,000 | `/100` for the account, `/10000` for metadata (verified 2026-08-23) |
| More collateral than planned (L/2 times more at target leverage L) | leverage stayed at the 2x default | explicitly call `update_leverage`; safe under an open cross-margin position |
| Trading stops because of leverage | an exception from setting leverage stopped the cycle | sizes do not depend on leverage: warn and trade with the current leverage |
| A market that rejects leverage consumes the write window | retry on every tick | per-market cooldown |
| Equity is doubled | “free spot balance” was added to `total_asset_value` | there is no free spot balance outside equity |
| Some positions are considered “valid” and others are not | parsing continued after a malformed entry | fail closed: the entire read is untrusted |
| “Empty account” from empty `accounts[]` | empty array was interpreted as “no positions” | this is a read error |

---

## Open questions / not verified

- Isolated margin (`margin_mode = 1`): placement, `update_leverage` behavior, and liquidation were not used.
- Liquidation formula through the maintenance fraction; where to obtain the market’s maintenance fraction (it was not consumed from `orderBookDetails`).
- Fees on the robinhoodchain instance; whether `*/USDG` and base markets differ.
- Semantics of `collateral` versus `total_asset_value` with unrealized PnL; exactly what `available_balance` includes when resting orders exist.
- Reading an account by address (`by=l1_address`) and listing an address’s sub-accounts were not verified.
- Fill history, funding, and PnL windows: the endpoints were not used, and “profit as the exchange calculates it” has not been studied for Lighter.

---

Facts verified through 2026-09-16. Verify response shapes and value scales using the exchange’s own arithmetic (the identity `Σ notional × imf = margin requirement`), not documentation.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license._
