src/account/positions.ts
v0.2.0 · 5 KB
// Positions, PnL and equity from a decoded `subaccount_info` plus the product universe.
//
// Formulas (knowledge base `account-and-fees.md` §2):
// size = perp_balances[].balance.amount (x18, sign = side)
// entryPx ~ |v_quote / amount| (estimate: includes accrued funding / realized quote on the leg;
// not verified against the UI as an average entry price)
// positionValue = |size| * oracle
// unrealizedPnl = size * oracle + v_quote (quote of the position not yet settled into USDT0)
// leverage = round(1 / (1 - long_weight_initial)), weight from perp_products[].risk when valid
// A position on a product the universe cannot NAME, or without an oracle, makes the read DEGRADED:
// acting on the rest as if that coin were flat is the partial-read hazard.
import { effectiveLongWeight, maxLeverageFromWeights } from '../markets/leverage.js';
import type { PerpProduct } from '../markets/types.js';
import { absX18, x18ToNumber } from '../numbers/x18.js';
import type { SubaccountInfo } from './parse.js';
export type PositionSide = 'long' | 'short';
/** One open position. */
export interface NadoPosition {
readonly coin: string;
readonly productId: number;
readonly side: PositionSide;
/** Signed size, x18. */
readonly sizeX18: bigint;
/** Signed size as a double (positive = long). */
readonly szi: number;
/** Absolute size. */
readonly size: number;
/** Quote leg (`v_quote_balance`) as a double. */
readonly vQuote: number;
/** Estimated average entry price (`|vQuote / szi|`). @experimental not verified against the UI. */
readonly entryPx: number;
readonly oraclePx: number;
readonly positionValue: number;
readonly unrealizedPnl: number;
readonly longWeightInitial: number;
/** Effective leverage implied by the weight (unified cross margin has no per-position setting). */
readonly leverage: number;
}
/** Why a position could not be decoded. */
export interface DegradedPosition {
readonly productId: number;
readonly reason: 'unknown-product' | 'no-oracle';
}
/** Result of {@link positionsFromInfo}. */
export interface PositionsResult {
readonly positions: readonly NadoPosition[];
/** False when at least one non-zero balance could not be decoded: do not treat that coin as flat. */
readonly ok: boolean;
readonly degraded: readonly DegradedPosition[];
}
/** Decodes the non-zero balances of `info` into positions using `byProductId` for names and weights. */
export function positionsFromInfo(
info: SubaccountInfo,
byProductId: ReadonlyMap<number, PerpProduct>,
): PositionsResult {
const positions: NadoPosition[] = [];
const degraded: DegradedPosition[] = [];
for (const [productId, bal] of info.balances) {
if (bal.amountX18 === 0n) continue;
const product = byProductId.get(productId);
if (!product) {
degraded.push({ productId, reason: 'unknown-product' });
continue;
}
const risk = info.products.get(productId);
const oraclePx = risk ? x18ToNumber(risk.oraclePriceX18) : 0;
if (!(oraclePx > 0)) {
degraded.push({ productId, reason: 'no-oracle' });
continue;
}
const szi = x18ToNumber(bal.amountX18);
const vQuote = x18ToNumber(bal.vQuoteX18);
const weightFromInfo =
risk?.longWeightInitialX18 !== null && risk?.longWeightInitialX18 !== undefined
? x18ToNumber(risk.longWeightInitialX18)
: null;
const longWeightInitial = effectiveLongWeight(weightFromInfo, product.longWeightInitial);
positions.push({
coin: product.coin,
productId,
side: szi > 0 ? 'long' : 'short',
sizeX18: bal.amountX18,
szi,
size: x18ToNumber(absX18(bal.amountX18)),
vQuote,
entryPx: Math.abs(vQuote / szi),
oraclePx,
positionValue: Math.abs(szi) * oraclePx,
unrealizedPnl: szi * oraclePx + vQuote,
longWeightInitial,
leverage: maxLeverageFromWeights(longWeightInitial),
});
}
return { positions, ok: degraded.length === 0, degraded };
}
/** Equity view of a subaccount. */
export interface NadoEquity {
/**
* Unweighted health in USDT0 (0 when the subaccount does not exist). Unified margin keeps the
* collateral inside the health: this is the whole equity, nothing is added on top.
*/
readonly accountValue: number;
readonly exists: boolean;
/** Sum of `unrealizedPnl` over decodable positions. */
readonly unrealizedPnl: number;
/** Sum of `positionValue` over decodable positions. */
readonly grossPositionValue: number;
}
/** Equity from one `subaccount_info` read (same request as positions, so both are from one moment). */
export function equityFromInfo(info: SubaccountInfo, byProductId: ReadonlyMap<number, PerpProduct>): NadoEquity {
const { positions } = positionsFromInfo(info, byProductId);
let unrealizedPnl = 0;
let grossPositionValue = 0;
for (const p of positions) {
unrealizedPnl += p.unrealizedPnl;
grossPositionValue += p.positionValue;
}
return {
accountValue: info.accountValue,
exists: info.exists,
unrealizedPnl,
grossPositionValue,
};
}