src/account/positions.ts
v0.3.0 · 6 KB
import * as D from './decimal.js';
import { parsePerpStates } from './parse.js';
import type { AccountSnapshot } from './types.js';
export interface NormalizedPosition {
/** `""` = main perp dex. */
dex: string;
/** HIP-3 coins keep their prefix (`xyz:NVDA`). Never split composite keys on `:`. */
coin: string;
/** Signed size (`szi`): > 0 long, < 0 short. */
size: number;
side: 'long' | 'short';
/** Average entry price, `null` when HL sends none. */
entryPx: number | null;
/**
* Mark price derived as `positionValue / |szi|` (HL reports `positionValue` at mark).
* Derived, informational; `null` never happens for an open position but is kept for safety.
*/
markPx: number | null;
/** Absolute notional at mark. Use this for exposure, not `|szi| × entryPx` (stale against the market). */
positionValue: number;
/** `sign(szi) × positionValue`. */
signedNotional: number;
liquidationPx: number | null;
/** Leverage is only visible while a position is open. */
leverage: { type: 'cross' | 'isolated'; value: number; rawUsd: number | null };
marginUsed: number;
unrealizedPnl: number;
/**
* `unrealizedPnl / marginUsed` as a fraction (0.364 = 36.4%), `null` when `marginUsed <= 0`
* (ROE cannot be computed reliably; treat as unknown, not as a loss).
* Equivalent to `dir × (mark − entry) / mark × leverage`: the denominator is MARK, not entry. At 4x and
* +10% the entry formula says 40%, the correct value is 36.4%; an ROE-based exit computed with the
* entry formula is off by 3.6 points.
*/
roe: number | null;
/**
* HL's own `returnOnEquity` (denominator = entry margin). Diverges from {@link roe} on cross after the
* price moves. Informational only: stops, UI and peaks must all use `roe`.
*/
returnOnEquityHl: number | null;
maxLeverage: number | null;
/** Exact decimal strings of the size and money fields. */
exact: { szi: string; entryPx: string | null; positionValue: string; unrealizedPnl: string; marginUsed: string };
}
/**
* Flattens open positions of every dex in a snapshot.
*
* A position is open when `szi != 0` and `positionValue != 0`. Validation is the same fail-closed parse
* used by the snapshot (non-numeric `szi` throws instead of reading as flat, duplicate coins across dexes
* throw). An empty result from a validated snapshot is a genuinely flat account.
*
* @throws {HlAccountError} `DEGRADED_PERP`, `INVALID_ARGUMENT`.
*/
export function normalizePositions(snapshot: Pick<AccountSnapshot, 'perp'>): NormalizedPosition[] {
const out: NormalizedPosition[] = [];
for (const dex of parsePerpStates(snapshot.perp)) {
for (const p of dex.positions) {
const sizeAbs = D.abs(p.szi);
const pv = D.toNumber(p.positionValue);
const marginUsed = D.toNumber(p.marginUsed);
const upnl = D.toNumber(p.unrealizedPnl);
const long = D.sign(p.szi) > 0;
out.push({
dex: dex.dex,
coin: p.coin,
size: D.toNumber(p.szi),
side: long ? 'long' : 'short',
entryPx: p.entryPx === null ? null : D.toNumber(p.entryPx),
markPx: D.isZero(sizeAbs) ? null : pv / D.toNumber(sizeAbs),
positionValue: pv,
signedNotional: long ? pv : -pv,
liquidationPx: p.liquidationPx === null ? null : D.toNumber(p.liquidationPx),
leverage: {
type: p.leverage.type,
value: p.leverage.value,
rawUsd: p.leverage.rawUsd === null ? null : D.toNumber(p.leverage.rawUsd),
},
marginUsed,
unrealizedPnl: upnl,
roe: D.sign(p.marginUsed) > 0 ? upnl / marginUsed : null,
returnOnEquityHl: p.returnOnEquity === null ? null : D.toNumber(p.returnOnEquity),
maxLeverage: p.maxLeverage,
exact: {
szi: D.toDecString(p.szi),
entryPx: p.entryPx === null ? null : D.toDecString(p.entryPx),
positionValue: D.toDecString(p.positionValue),
unrealizedPnl: D.toDecString(p.unrealizedPnl),
marginUsed: D.toDecString(p.marginUsed),
},
});
}
}
return out;
}
/**
* Coins that appear in `assetPositions` with `szi == 0` or `positionValue == 0`, per dex.
*
* {@link normalizePositions} does not return them (they are not open positions), but a zero-size row is
* "no data", not a close: HL transiently returns such rows for live positions. Keep these coins in your
* set of active keys (and log a warning) instead of treating them as closed; otherwise the next healthy
* read produces a phantom new position after a false close.
*
* @throws {HlAccountError} `DEGRADED_PERP`, `INVALID_ARGUMENT` (same validation as the snapshot).
*/
export function listZeroSizePositions(snapshot: Pick<AccountSnapshot, 'perp'>): Array<{ dex: string; coin: string }> {
return parsePerpStates(snapshot.perp).flatMap((d) => d.zeroSizeCoins.map((coin) => ({ dex: d.dex, coin })));
}
/**
* Account-level PnL% of open positions: `Σ unrealizedPnl / Σ marginUsed` (fraction), summed exactly.
* `null` when total margin is <= 0.
*/
export function aggregateRoe(positions: readonly Pick<NormalizedPosition, 'exact'>[]): number | null {
let pnl = D.ZERO;
let margin = D.ZERO;
for (const p of positions) {
const u = D.parseDec(p.exact.unrealizedPnl);
const m = D.parseDec(p.exact.marginUsed);
if (!u || !m) return null;
pnl = D.add(pnl, u);
margin = D.add(margin, m);
}
return D.sign(margin) > 0 ? D.toNumber(pnl) / D.toNumber(margin) : null;
}
/**
* ROE from prices, for a fast price tick between account reads (fraction):
* `dir × (mark − entry) / mark × leverage`, which equals `unrealizedPnl / marginUsed` when
* `marginUsed = positionValue / leverage` (cross, margin at mark).
* Returns `null` for non-positive prices or leverage.
*/
export function roeFromMark(args: { side: 'long' | 'short'; entryPx: number; markPx: number; leverage: number }): number | null {
const { side, entryPx, markPx, leverage } = args;
if (!(entryPx > 0) || !(markPx > 0) || !(leverage > 0)) return null;
const dir = side === 'long' ? 1 : -1;
return (dir * (markPx - entryPx) / markPx) * leverage;
}